MHL META-HARNESS LANGUAGE
GitHub ↗

MHL · SELF-CONTAINED REFERENCE · v1.4 RELEASE CANDIDATE

The language, from literals to loops.

Everything you need to read and write an MHL program is on this page: practical grammar, behavior, commands, and complete examples. No explanation depends on internal project documents.

Executable available through the normal CLI workflow Alpha contract declarable and inspectable, with integration still evolving
RELATED GUIDE

Need to add a capability that is not built in? Read Extending the runtime for manifests, the process protocol, permissions, and installation.

01Programs and modules

Executable

A program is a sequence of top-level declarations. Mark a declaration export to publish it, and import { ... } from "file.mh" to bring named declarations in from another file, optionally under a local alias. import is the only cross-file mechanism.

TWO-FILE EXAMPLE

shared.mh
export agent Echo {
    command: "echo"
    args: ["${prompt}"]
}

export prompt Greeting(name: string) {
    "Hello, ${name}!"
}
main.mh
import {Echo, Greeting as Hello}
    from "shared.mh"

pipeline Welcome {
    input name: string
    step Run {
        log(Echo.run(
            prompt: Hello(name: name)
        ))
    }
}
FormSyntaxWhen to use it
Exportexport prompt Name(...) { ... }Make a declaration reusable.
Import namesimport {A, B} from "file.mh"Bring in explicit contracts only.
Local aliasimport {LongName as Short} from "file.mh"Avoid collisions and improve readability.
Whole fileimport "file.mh"Merge every declaration, exported or not — see splitting a partial pipeline/workflow across files.
NOTE

import merges the target file's declarations into the program's flat namespace (transitively — an imported file's own imports come along), so a name resolves the same however it arrived. There is no namespace-qualified import. The brace-less import "file.mh" form skips the per-name export check entirely (there is no name list to check it against) and merges everything the file declares — mainly useful for a partial fragment, which has no reason to individually name and export itself.

02Syntax and types

Executable

MHL uses blocks, does not require semicolons, and allows object fields to be separated by commas or line breaks. Comments start with //.

LITERALS

var absent = null
var enabled = true
var attempts = 3
var temperature = 0.2
var timeout = 45s
var title = "feature audit"
var message = """
Multiline text with ${title}.
"""
var tags = ["security", "backend", 2026]
var job = {
    id: 42
    status: "pending"
    metadata: { priority: 1, owner: "platform" }
}
NUMBERS & DURATIONS

A number is digits with an optional single decimal point (3, 0.2) — it carries no type suffix. A duration is an integer followed by a unit: ms, s, m, h, d (45s, 7d). A digit run glued directly to letters that is not a valid duration (10.0d, 1e5, 5days) is a parse error, not a number followed by a name. A string literal is always a string — even when its content is exactly an operator, so ["a","b"].join("-") is fine. duration is a literal form, not a declarable type: it is accepted only where an operation or a config field expects one (timeout:, ttl:, time.add, a step's timeout clause). Annotating a parameter : duration fails at run time with unrecognized type.

DECLARABLE TYPES

TypeExampleUse
Primitivesstring · number · bool · anyInputs, parameters, returns, and contracts. int/integer/float spell number; boolean spells bool.
Arraysstring[] · number[][]Add [] for each level.
Objects{id: number, name: string}Structural shape with named fields.
Composites{id: number, tags: string[]}[]Arrays of typed objects.
PromptpromptA parameter that receives another prompt's rendered text.
tool Catalog {
    first(items: {id: number, tags: string[]}[]): {id: number, tags: string[]} -> items[0]
}

pipeline Import {
    input records: {id: number, active: bool}[]
    step Count { log(records.size()) }
}

TYPE ALIASES

type Name = <type> names a type expression so a repeated or verbose shape is written once. An alias is only a spelling — it resolves to exactly the type its target denotes, adds no distinct type, and is usable anywhere a : type annotation is (inputs, parameters, returns, shape fields). Aliases may reference other aliases. A cycle, a duplicate name, an unknown target, or a name that shadows a builtin keyword is a static error (mhl lint).

type Slug      = string
type Ids       = string[]
type Violation = { code: string, severity: string }

tool Report {
    add(slug: Slug, issues: Violation[]): Ids -> issues.map((v) -> v.code)
}
OPTIONAL FIELDS

A trailing ? on a shape field's name — type ReviewInput = { diff: string, base?: string } — makes that field optional: a value may omit it, and when present it is still checked against its type. An optional field is left out of the JSON Schema's "required" list (an MCP inputSchema/outputSchema). A type carries no default values; when a typed signature's param omits an optional field it reads as null, so the default is written where it is used: req.base ?? "main". A ? is valid only there — never as an operator.

BUILTIN OBJECT TYPES

Three object shapes — SessionContext, StepContext, FailureContext — are declared by the runtime itself, resolving anywhere a name like string/object already does, alongside any alias your own program declares (and, like a keyword, none of the three can be redeclared). They are what a pipeline/workflow's five lifecycle hooks (§14, LIFECYCLE HOOKS) bind their own lambda parameter to — that parameter is never annotated in practice, since the shape follows from which hook it is, but the type still exists for real: an editor lists SessionContext's fields the moment you type session. inside session_start/session_end, exactly as it would list a declared alias's fields anywhere else a : Type position resolves one.

VALUES AND REFERENCES

Every { … } object and [ … ] array is a value: assigning it, passing it to a lambda or tool, or nesting it in another object hands over an independent copy, so two names never share a structure one of them changes. When sharing is what you want, create a reference object with ref { … } — or ref (expr), from a copy of any object. Every name that holds it sees every change, and == compares identity (the same object), not fields.

var a = { x: 1 }
var b = a
b["x"] = 2              // a.x is still 1 — b is a copy

var counter = ref { n: 0 }
var alias = counter
alias["n"] = 1          // counter.n is 1 — one shared object
counter == alias        // true: same object
ref { n: 1 } == ref { n: 1 }   // false: two objects
ACROSS A RESUME

A reference object keeps its identity through a checkpoint: it is saved with an internal id, and names that shared it before a pause (or a crash) share it again after --resume / run/resume — so a resumed run behaves exactly like one that never stopped. A reference object may even contain itself. Checkpoints written before this (state schema 1) still load.

AT THE BOUNDARY

A reference object never leaves the interpreter as one. JSON (json.stringify, log, ${…}), native ops, extensions, memory, mem and a run's result (output:, MCP, A2A) all see the plain object it holds at that moment; a secret inside it is masked like any other. A mem therefore stores a snapshot, not the shared object. One that contains itself has no JSON form: returning it is an error (a paused run's partial state shows the loop as "[circular ref]").

ref IS CONTEXTUAL

Only ref { and ref ( start a reference object; ref is still usable as a variable or argument name (git.rev_parse(ref: "HEAD")). ref of something that isn't an object, or that already is a reference object (assign it to share it), is an error.

obj with { field: value, ... } copies obj and overrides the listed fields on the copy, leaving obj itself untouched — "the same object, except for these fields", without rewriting every other field by hand.

var d = { titulo: "x", avisos: [], outro: 1 }
var d2 = d with { avisos: ["a"] }
// d.avisos is still [] — d2 is a copy with just avisos overridden
// d2.titulo and d2.outro are unchanged: "x" and 1

var chained = { a: 1 } with { b: 2 } with { c: 3 }   // { a: 1, b: 2, c: 3 }, applied left to right
with AND REFERENCES

with copies the same way an ordinary object literal does: a plain field is deep-copied, a ref { ... }-typed field keeps its shared identity. with on a reference object itself always returns a plain copy — never a second name for the same shared identity.

with IS CONTEXTUAL

Only with { continues an expression this way; with is still usable as a variable or argument name — there is no bare-object-after-identifier construct anywhere else in the grammar to collide with. The left side must already be an object: 5 with { x: 1 } is an error. A field access on a with result needs parens: (obj with { x: 1 }).x.

ENUMS

enum Name { A, B, C } declares a closed set of named constants. A value is produced only by qualified access — Name.A — and at run time it is a distinct tagged value, not a string: Status.Draft == "Draft" is false, type_of(Status.Draft) is "enum", and "${Status.Draft}" / json.stringify render the bare variant (Draft). : Name is a type annotation like any other. A match over an enum value is checked for exhaustiveness by mhl lint.

enum Status { Draft, Published, Archived }

pipeline Publish {
    input status: Status
    step Decide {
        var note = match status {
            Status.Draft     -> "hold"
            Status.Published  -> "ship"
            Status.Archived   -> "skip"
        }
        log(note)
    }
}
ENUM INPUTS

An input typed with a declared enum — input status: Status above — closes its value set the same way in the inputSchema exposed via mhl serve mcp/a2a's tools/list: it renders as {"type": "string", "enum": ["Draft", "Published", "Archived"]}, not a bare {"type": "string"} — so a caller (or a client's own form/UI generated from the schema) sees the closed set of legal values up front instead of discovering it by trial and error. The text a caller sends (--input status=Draft, a JSON "Draft", W.run(inputs: {status: "Draft"})) is converted to the enum value before the first step — also inside an object or array input — so status == Status.Draft and an exhaustive match work directly on it; a name that is not a variant is rejected up front, listing the valid ones. An enum held in a pipeline var is still an enum after a checkpoint/resume.

PARSE AND VALUES

For text that does not arrive through a typed input — a field of a JSON file, a tool argument — Status.parse(text) returns the variant named text (raising, with the variant list, when there is none; a value already of this enum is returned unchanged), and Status.values() lists every variant in declaration order. The call is what tells them apart from a variant access, so an enum may still declare a variant named parse or values. Comparing an enum-typed name with a string literal (status == "Draft") is a mhl lint error: it is always false.

PRACTICAL GRAMMAR

declaration  := [export] (import | agent | prompt | memory | tool | extension | pipeline | workflow | type | enum | test)
import       := "import" "{" name ["as" alias] {"," name ["as" alias]} "}" "from" string
              | "import" string                                 // whole-file form: merges every declaration, export or not
pipeline     := ["partial"] ["loop"] ("pipeline" | "workflow") identifier ["max" number] "{" {member} "}"
step         := ["entry"] "step" identifier ["timeout" duration] "{" {statement} "}"   // "entry" only valid, and required exactly once, inside a "partial" declaration
extension    := "extension" kind identifier "{" {property} "}"   // kind is "mcp", "a2a" (built in), or an installed adapter's kind
type-alias   := "type" identifier "=" type
enum         := "enum" identifier "{" [identifier {"," identifier}] "}"
variable     := "var" identifier "=" expression
constant     := "const" identifier "=" expression   // single-assignment; reassigning is an error
assignment   := lvalue ("=" | "+=") expression   // "+=" is sugar for `x = x + expr`
property     := identifier ":" expression                        // declaration-body properties are newline-separated, not comma-separated
call         := expression "(" [argument {"," argument}] ")"
array        := "[" [expression {"," expression}] "]"
object       := "{" [key ":" expression {","? key ":" expression}] "}"
parameters   := parameter {"," parameter}
parameter    := identifier [":" type] ["=" expression]   // default: used when a caller omits the argument
lambda       := "(" [parameters] ")" "->" (expression | block)
if-expr      := "if" "(" expression ")" expression "else" expression
match-expr   := "match" expression "{" {(pattern | "_") "->" expression} "}"   // pattern: Enum.Variant or a bool/number/string literal
optional     := expression "?." identifier      // null / non-object / missing-field safe
optional     := expression "?." "[" expression "]"  // same, with a dynamic key
coalesce     := expression "??" expression      // left unless it is null
with-expr    := expression "with" object        // copy of expression with object's fields overridden; chains left to right

03Expressions and collections

Executable

Expressions produce values. They compose inside variables, arguments, properties, conditions, returns, and string interpolation.

PRECEDENCE — LOWEST TO HIGHEST

??→||→&&→== !=→< > <= >=→+ -→* / %→! -→. ?. () []
var ready = retries < 3 && files.size() > 0
var label = if (ready) "ready" else "blocked"
var total = price * quantity + shipping
var dynamic = record[field_name]
var slice = numbers[1..^1] // index 1 through one before the end
var text = "${label}: ${total}"

// `?.` reads through null, a non-object, or a missing field without raising;
// `?.[key]` is the same with a dynamic key; `??` supplies a default only
// when the left side is null.
var target = metric?.target?.trim() ?? ""
var retries = config?.retries ?? 3       // 0 stays 0 — only null is replaced
var field  = record?.[name] ?? null      // no `is_object(record)` guard needed
NESTED QUOTES

A ${...} interpolation may contain its own ordinary, unescaped string literal — "today is ${time.format(time.now(), "yyyy-MM-dd")}" — nested to any depth. Escaping the inner quotes (\") still works, for source written before this was supported, but is no longer necessary.

SHORTHAND

A bare name in an object literal is a field of the same name — {artifact, path, n: 1} is {artifact: artifact, path: path, n: 1} — and a named argument with no value passes the variable of the same name — Writer.generate(prompt:, schema_file:, project_id:). Both are expanded by the parser, so they evaluate, lint and hash (checkpoint --resume digest) exactly like the long form. Only an identifier abbreviates: a keyword ({return}) or a string key ({"a"}) does not.

UNDEFINED NAMES

mhl lint checks every ${...} span the way the runtime evaluates it: a span that does not parse, or a name inside it that resolves to nothing — catch { log.error("${e}") } with no (e) binding — is an error. So is an unknown receiver anywhere in a step, hook, tool method or test (Missing.pick(), Statuz.Ok): neither is otherwise reported until that line runs, and inside a try or a lifecycle hook the runtime error is easy to swallow. The prompt:/schema: arguments of Agent.run(...) and Router.delegate(...) are exempt — they also interpolate the agent before hook's returned fields, which lint cannot see.

MATCH

match is a multi-way branch in expression position. Arms are tried top to bottom; the first whose pattern is deep-equal to the subject (the same comparison == uses) wins, or the _ wildcard. A pattern is an Enum.Variant or a bool/number/string literal. A match over an enum or bool with no _ arm must cover every case — mhl lint flags a missing one; any other subject relies on a runtime error if nothing matches.

var tier = match http_status {
    200 -> "ok"
    404 -> "missing"
    _   -> "error"
}

VALUE METHODS

Common

size()is_empty()equals()deep_equal()

Strings, arrays, and objects. equals works on any value.

String

split()replace()contains()starts_with()ends_with()matches()trim()to_upper()to_lower()substring()remove()remove_content()extract_content()

Array

get_index()index_of()contains()filter()find()sort_by()map()reduce()any()all()append()join()unique()enumerate()

Object

keys()values()get()

Keys are returned in stable order. get(key, default?) never raises on a missing field.

SIGNATURES

Every method's parameters, types, and return value are in the specification’s standard-library clause.

RUNTIME TYPES

type_of(x) returns a kind string ("string", "number", "bool", "array", "object", "null", …); is_string, is_number, is_bool, is_array, is_object, is_null are the per-kind predicates. Pair them with ?. to branch on a dynamic field's shape.

NAMEOF

nameof(Name) takes a bare declared name — an agent, router, memory, tool, prompt, skill, pipeline/workflow, extension, type, or enum — and returns it as a string, validated to exist. Prefer it over a string literal anywhere a declared name is needed as text (the router's select hook is the main case): a typo in nameof(Billing) is a mhl lint error, and the identifier is a real reference an editor's "go to definition" follows.

DETERMINISTIC SELECTION EXAMPLE

var passed_ids = [10]
var features = [
    {id: 20, status: "pending", priority: 2, depends_on: [10]},
    {id: 30, status: "pending", priority: 1, depends_on: [99]},
    {id: 40, status: "pending", priority: 3, depends_on: []}
]

var ready = features
    .filter((f) -> f.status == "pending")
    .filter((f) -> f.depends_on
        .filter((d) -> !passed_ids.contains(d))
        .is_empty())
    .sort_by((f) -> f.priority)

var next = ready.find((f) -> f.id == 20)
IMMUTABLE RESULTS

filter(), map(), sort_by(), append(), unique(), concatenation, and slices return new arrays. To accumulate into a list, reassign — acc = acc.append(x) or acc += [x]. Assigning to items[index] or object[key] mutates the original collection.

04Control flow

Executable

Statements perform actions inside a step, a tool method, a block lambda, or a test case.

ConstructSyntaxEffect
Variablevar x = expressionCreates a local binding.
Constantconst x = expressionSingle-assignment binding. Reassigning it (x = … / x += …) is an error at mhl run and mhl lint. Re-running the declaration itself — a const in a loop body — just rebinds. Also valid at pipeline top level.
Assignmentx = expressionUpdates a variable, field, or index.
Destructuringvar {data, revisao: note} = r · {a, b} = r · self.{tokens_in, tokens_out} = rBinds several fields of one object at once: declares locals (var), assigns existing names, or assigns the pipeline's own vars (self.). field: name renames. The right-hand side is evaluated once; a missing field fails like r.field. Exactly equivalent to one var/assignment per field, so lint checks each target the same way (a const, an undeclared self.name). Inside a brace-less if, write if (c) { {a} = r } — a bare {a} there is read as the if's block.
Add-assignx += expressionSugar for x = x + expression — same operand rules as +: two numbers, two arrays, or a string with a string, number, bool or enum value ("n=" + 3 → "n=3", formatted as ${...} would). A string with null, an object or an array is still an error — almost always a bug; use interpolation when it is intended. Handy for accumulating into a list: errors += ["..."].
Conditionif (cond) { ... } else { ... }Executes a block by condition.
Repetitionwhile (cond) { ... }Repeats while the condition is true.
Iterationfor (var item in items) { ... }Iterates over an array.
Error handlingtry { ... } catch (err) { ... } finally { ... }Handles recoverable failures.
Returnreturn valueExits a method or step.
Transitiongoto StepName · goto nameof(StepName)Jumps to another step, forward or backward. Only inside a workflow (not a plain pipeline); the target must be a step of that workflow. The nameof(...) spelling is sugar for the same bare name — it's not the nameof(...) builtin evaluated as a call, just a way to write a goto target that reads like a router's select: () -> nameof(Billing).
Dispatchgoto match artifact { "brief" -> Brief _ -> fail("unknown") }Chooses a step by the same first-match equality as expression match. Destinations are declared step identifiers, checked by lint; duplicate patterns and arms after _ are reported. Without a matching arm, the run fails.
Stopbreak "reason"Ends the current pipeline or loop early — a clean stop (exit 0, not a failure) that keeps the run's variable state as its result.
Pausepause("reason")Suspends the run at this step for a human-in-the-loop hand-off — not a failure, not a completion. Keeps the checkpoint; mhl run --resume / run/resume {runId, arguments} re-enters this step with the merged decision.
Completecomplete()Ends the run in the normal "completed" state right here, no matter what's declared after this step — the only other way to reach "completed" is running off the very end of the merged step list. Use it on a convergence/terminal step instead of relying on physical position.
tool Summary {
    count_valid(items: {valid: bool}[]): number {
        var count = 0
        for (var item in items) {
            if (item.valid) count = count + 1
        }
        return count
    }
}

pipeline SafeRead {
    step Read {
        try {
            var config = json.parse(fs.read("config.json"))
            log.info("mode:", config.mode)
        } catch (err) {
            log.warn("invalid configuration:", err)
        } finally {
            log.info("read completed")
        }
    }
}
ERRORS

fail("message") creates a failure that catch can handle. Without a handler, the failure stops execution and the CLI returns a nonzero status.

if (flag) return
log.info("unreachable")   // parse error: read as `return log.info(...)`, not two statements
!
return/break's value must start on the same line.

mhl has no statement terminator — a newline is ordinary whitespace. return/break's optional value is parsed greedily, so one left on its own line (as above) reads the next statement as that value instead of a separate statement — a parse error rather than a silent misread. Fix it by bracing the inline branch (if (flag) { return }) or restructuring so nothing follows the bare keyword directly. A value on the same line is unaffected, even one that itself continues onto further lines (return { then more fields below).

05agent

Executable

Declares an AI backend callable through .run(prompt: ...). The runtime supports local CLI processes and models served by Ollama.

CLI AGENT

agent Claude {
    engine: "cli/claude-code"
    command: "claude"
    args: [
        "-p", "${prompt}",
        "--output-format", "json",
        "--json-schema", "${schema}"
    ]
    retry: {
        max_attempts: 3
        delay: 2s
        retry_on: [500, 503, "timeout", "rate_limit"]
    }
    cache: { ttl: 10m, storage: "disk" }
    rate_limit: { requests_per_minute: 20, concurrency: 2 }
}

CLI AGENT — PROMPT VIA STDIN

// Codex CLI reads its prompt from stdin when the positional argument is
// "-" — keeping an unboundedly large prompt (e.g. concatenated source
// documents) entirely out of argv, which matters on Windows specifically
// (see the WINDOWS tip below). `--output-schema` still takes a file path
// per Codex's own CLI, not inline JSON — `stdin:` only removes the prompt
// from argv, not a schema a given backend requires as a file.
agent Codex {
    engine: "cli/codex"
    command: "codex"
    args: ["exec", "-"]
    stdin: "${prompt}"
}

// Claude Code CLI's `-p` argument is a fixed instruction, not the content
// itself — the large piece (a document, a wiki page) goes through `stdin:`
// as separate reference material `-p` refers to. See the PROMPT INJECTION
// tip below before writing that material as anything that reads like a
// command rather than data to summarize.
agent Claude {
    engine: "cli/claude-code"
    command: "claude"
    args: ["-p", "Summarize the document piped on stdin in exactly one sentence."]
    stdin: "${prompt}"
}

OLLAMA AGENT AND CALL

agent LocalCoder {
    engine: "ollama/qwen2.5-coder"
    endpoint: "http://localhost:11434"
    temperature: 0.1
    fallback: [
        agent {
            command: "echo"
            args: ["fallback: ${prompt}"]
        }
    ]
}

var schema = """
{"type":"object","properties":{"summary":{"type":"string"}}}
"""
var raw = LocalCoder.run(
    prompt: "Summarize the change.",
    schema: schema
)
var result = json.parse(raw)
FieldExampleBehavior
engine"cli/name" · "ollama/model"Selects the adapter.
command / args"claude" · env("MODEL_CMD") · ["-p", "${prompt}"]Builds argv without splitting elements. Either may be any expression, not just a string literal — command: env("MODEL_CMD") picks a backend per environment from one declaration. A bare string literal element (including the "${prompt}"/"${schema}" placeholder tokens) is used exactly as written, with no ${...} interpolation; anything else (env(...), concatenation, a variable) is evaluated, failing closed on a missing credential the same way an extension's env(...) does — reachable through an if/match branch or not. For a value that's genuinely optional (not a credential — a working directory, say), use env("VAR", "default") instead of an if/else workaround: the 2-argument form is exempt from that fail-closed requirement and redaction on purpose. The "${prompt}"/"${schema}" placeholder tokens are the only two recognized — only as a whole args: element, never inside command:, never embedded partway through a longer string; mhl lint flags any other "${...}"-shaped text there, since it would otherwise reach the real subprocess argv unsubstituted with no warning.
stdin"${prompt}" · "{\"content\": \"${prompt}\"}"cli/* engine: text written to the child process's standard input instead of argv. Keeps large content — a big ${prompt}, a big ${schema} — out of the command line entirely, which matters on Windows: CreateProcess caps the whole command line (executable + every argument, joined) at ~32,767 characters, and a large prompt interpolated into args: can exceed that with no clue beyond a raw OS error ("The filename or extension is too long"). Unlike args:, "${prompt}"/"${schema}" are substituted wherever they appear in the string, not only when the whole value is the placeholder — so an agent can compose stdin around them, e.g. a backend's own JSON stdin protocol. Once stdin: claims "${prompt}", it is not also appended to args: — leaving args: exactly as declared. A schema placeholder with no schema: argument supplied fails the same way it does in args:.
log"logs/run.${context.session_id}.log"cli/* engine: appends every call's raw stdout to this path. Interpolated for ${...} spans like a prompt string, so each run (or --session) can get its own file instead of concurrent runs interleaving into one. Ignored under spawn.
endpoint / temperature"http://localhost:11434" · 0.2Configures Ollama.
retry{max_attempts: 3, delay: 2s}Repeats eligible failures.
cache{ttl: 1h, storage: "disk"}Reuses a response by exact key.
rate_limit{requests_per_minute: 20, concurrency: 2}Controls frequency and concurrency.
fallback[Backup, agent {...}]Tries agents in declaration order.
skills[CodeReview, SecurityReview]Allow-list of declared skill names this agent may receive via run(skills: [...]). Requires system_prompt — mhl lint rejects a non-empty skills with no way to receive them.
system_prompt(skills, prompt) -> "..."Composes the outgoing prompt when a call selects at least one skill. Its return replaces the plain prompt: text wherever "${prompt}" is used in args (or in the Ollama request body) — see §08 skill.
before() -> {...}Runs once, before the prompt is built.
after() -> {...}Runs once, on the final response (bound as result).
description"Handles invoices and refunds"Optional; no runtime effect on the agent itself. A router's decider folds it into the decision prompt next to the agent's name, giving the decision call more to go on than a bare name.
ALPHA

retry.backoff, cache.strategy and rate_limit.on_exceeded each accept exactly one value today — "exponential", "exact" and "queue" respectively. Any other value is a build-time error at both mhl lint and mhl run. Fields the older docs may still show (api_key, timeout, system_instructions) are not read and are now a lint error.

RETRY

The exponential backoff carries full jitter (each wait is a uniform pick in [0, delay·2ⁿ]) and is capped at 30s per wait — the cap applies from the very first wait, so a delay larger than it is trimmed immediately rather than sleeping once at the full amount — so a large max_attempts never grows an unbounded sleep. The wait is governed by the run's context: a run-level cancel, a timeout clause, or a served run/cancel aborts a retry that is only sleeping, rather than blocking until the delay elapses. A cancelled or deadline-exceeded call is never retried, whatever retry_on says; the caller then sees the last real failure, not the cancellation.

WINDOWS

A prompt built from unbounded source content (an entire wiki, a full document) can push args:'s "${prompt}" past Windows's ~32,767-character command-line ceiling with no warning until mhl run fails on that platform specifically — CreateProcess refuses to start the child process at all, and the error surfaces as raw OS text ("The filename or extension is too long") with no hint the real cause is argv size. macOS/Linux have no equivalent limit worth guarding (their execve ARG_MAX is 1-2MB), so this only bites on Windows, and typically only in production — a development machine on macOS/Linux runs the exact same pipeline without error. Move the large piece to stdin: instead of args: to sidestep it entirely.

PROMPT INJECTION

Some backends apply their own prompt-injection heuristics to stdin: content, separately from anything mhl does — the Claude Code CLI in particular can refuse to respond at all when piped stdin content reads like an instruction rather than reference material. Confirmed against the real CLI: piping "THE SECRET WORD IS BANANA" alongside a -p instruction that referenced it got refused outright ("This appears to be a prompt injection attempt..."), while piping the same kind of content framed as a document to summarize (no embedded imperative) was answered normally. When an agent's args: carries a fixed instruction and stdin: carries separate reference material to analyze (the Claude pattern above) — as opposed to a backend like Codex's -, which is designed to read the whole instruction from stdin — keep that material read as data, not phrased as a command the model should follow.

!
Do not place secrets in args or stdin.

Prefer environment variables resolved by the invoked process. To preserve spaces and special characters in prompts, keep "${prompt}" as one complete array element in args:, or route it through stdin: instead.

BEFORE / AFTER HOOKS

An agent is a single-shot CLI process — it cannot decide mid-generation to call a tool or an MCP server. before/after are how the pipeline does that calling on the agent's behalf: real values, fetched before the prompt exists, not a hope the model reads and acts on an instruction.

agent Reviewer {
    command: "claude"
    args: ["-p", "${prompt}"]

    before: () -> {
        return {
            issue: GitHub.call("get_issue", { number: 42 }),
            diff: execution.git_diff()
        }
    }
    after: () -> {
        log("review is ${result.size()} characters")
    }
}

var review = Reviewer.run(
    prompt: "Issue: ${issue}\nDiff: ${diff}\nWrite a review."
)
  • A hook body is an ordinary expression scope: it calls declared tool/extension/agent names, native ops (http, cmd, …) and mem the same way a step does. Both hooks take no parameters — a leftover (mcp, tools) -> is an error.
  • Reads the caller's variables; writes never come back. Like any lambda, a hook closes over the scope that issued the .run(...) call — a step's, or an intermediary tool method's — as a one-time snapshot, not a live view: the body sees each captured variable's value as it stood when the hook started, but a bare name = ... assignment inside the body is accepted with no error and simply never reaches the caller's real variable, at any call depth — even called directly from a step, with no tool in between. A hook's only way to actually hand data back is its own return value (below), or an external effect it performs itself (a tool/extension call, mem).
  • before's returned object's fields become real "${...}" bindings available to that call's prompt:/schema: arguments — the one channel a hook has for handing structured data back to its caller.
  • after runs once, against whichever leg — the primary agent or a successful fallback — produced the final response, with it bound in the body as result. Its return MUST be a string or nothing: a non-null string replaces the response, no return (or an explicit null) passes it through unchanged, and any other type — an object included — raises "<Agent>.after must return a string (or nothing), got <type>". Unlike before, after cannot hand back structured data through its return value.
REAL CALLS

before/after execute real calls through mhl itself — the model never has to cooperate, and there is no separate declarative tool-scope list on the agent. When an agent needs a tool or an extension, call it from before and pass the result in through the prompt.

SKILLS

A skill (§08) is a reusable instruction package loaded from a Markdown file. skills: on the agent is an allow-list; run(skills: [...]) selects a subset of it, in call order; system_prompt is the only place that selection actually reaches the outgoing request.

skill CodeReview from "code-review.md"
skill SecurityReview from "security-review.md"

agent Reviewer {
    command: "claude"
    args: ["-p", "${prompt}"]
    skills: [CodeReview, SecurityReview]

    system_prompt: (skills, prompt) -> {
        var instructions = ""
        for (var s in skills) {
            instructions = instructions + "## " + s.frontmatter.name + "\n" + s.content + "\n\n"
        }
        return instructions + prompt
    }
}

var review = Reviewer.run(
    skills: [CodeReview, SecurityReview],
    prompt: "Review this diff."
)
  • A call that never writes skills: at all never invokes system_prompt — the request sent is exactly what prompt: resolves to, identical to an agent with no skills feature in play, even on an agent that declares both skills: and system_prompt.
  • system_prompt's first parameter is a list of {name, frontmatter, content} objects, one per selected skill, in call order. name is the skill's MHL declaration identifier — the same string nameof(CodeReview) resolves to, so a hook can compare by identity (s.name == nameof(CodeReview)) instead of a free-floating string. frontmatter is the parsed frontmatter block (frontmatter.name/frontmatter["name"] both work); content is the Markdown body with the frontmatter stripped out.
  • Selecting a skill the agent didn't permit, repeating one, or naming one that isn't declared all fail before any model call — in both mhl lint and mhl run.
  • Retry and fallback reuse the exact same already-composed prompt: system_prompt runs once, before the primary attempt, never per retry and never again for a fallback leg.

06router

Executable

Declares a decision layer over a set of already-declared agents and exposes two methods: .delegate(prompt: ...), that picks which one should handle a given prompt and runs it — the same call shape as agent.run(...), with routing built in — and .select(prompt: ...), that resolves the same decision without running the chosen agent, for a caller that needs to know the answer first. The LLM decision call is optional: a router that declares only select is a purely deterministic router.

HYBRID DECISION

agent Billing {
    command: "claude" args: ["-p", "${prompt}"]
    description: "Handles invoices, billing questions, and refunds"
}
agent Support {
    command: "claude" args: ["-p", "${prompt}"]
    description: "Handles account access and technical issues"
}
agent Judge { engine: "cli/claude-code" command: "claude" args: ["-p", "${prompt}"] }

router Frontdesk {
    agents: [Billing, Support]

    // Tried first: a deterministic hook over the prompt text. A match
    // skips the LLM decision call entirely. nameof(Billing) — not the
    // string literal "Billing" — so a typo here is caught by mhl lint.
    select: (prompt) -> {
        if (prompt.contains("invoice")) { return nameof(Billing) }
        if (prompt.contains("refund")) { return nameof(Billing) }
        return null
    }

    // Used only when select is absent or returns nothing that names a
    // declared agent: Judge is an ordinary, independently declared agent
    // — nothing about its call configuration is duplicated on the
    // router — sent one "pick one of these agents" decision call built
    // from the prompt already passed to .delegate(...).
    decider: Judge
}

var reply = Frontdesk.delegate(prompt: "My invoice is wrong.")

PURELY DETERMINISTIC — NO LLM CALL AT ALL

router Frontdesk {
    agents: [Billing, Support]

    // No decider declared at all: there is no decision call to cascade
    // to, so select alone must be exhaustive.
    select: (prompt) -> {
        if (prompt.contains("invoice")) { return nameof(Billing) }
        return nameof(Support)
    }
}
FieldExampleBehavior
agents[Billing, Support]Required. Names of already-declared agents .delegate() may choose among.
select(prompt) -> { ... }A one-parameter hook run first; a returned string naming a declared agent (write it as nameof(Billing), not "Billing") picks it directly, skipping the decision call. A null/absent return cascades to the decision call — if one is configured. A non-null return that names no declared agent is always an error, never a silent cascade. Also callable directly as Router.select(prompt: ...), independent of .delegate() — see the SELECT tip below.
deciderJudge · agent {...}Optional. A declared agent name (or inline agent {...} literal) the decision call runs through — its own engine/command/args/endpoint/temperature/retry/cache/log/trace apply unchanged; nothing is duplicated on the router. Omit entirely for a purely deterministic router driven only by select.
DESCRIPTION

The decision prompt sent to decider lists each of agents' bare names. When an agent declares its own optional description property, that text is appended right after its name — no separate per-agent description list is declared on the router itself. An agent with no description still contributes its bare name.

DELEGATE

.delegate(prompt: ..., schema?: ...) resolves the agent to run — via select, else the decision call — then runs that agent with the same arguments the caller passed, returning its response unchanged. The chosen agent's own retry/cache/rate_limit/fallback/before/after all apply exactly as they would for a direct .run(...) call — and so does the decider's, for the decision call itself.

SELECT

.select(prompt: ...) resolves the router's decision the same way .delegate() does, but only runs the select hook — it never runs the chosen agent, and never falls back to a decider's LLM cascade. It returns the chosen agent's name as a string, or null if select declined to decide. Calling .select() on a router with no select hook (a purely LLM-decided one) is a mhl lint error — there is nothing deterministic to report ahead of a real .delegate() call. Use this when a caller needs to know which agent will handle a prompt before building the rest of the call — e.g. two backends that need the same logical argument shaped differently.

OPTIONAL LLM CASCADE

The decision call is opt-in, not implied by declaring a router: it only exists when decider is declared. A router with neither a decider nor a select hook is rejected by mhl lint — it could never resolve an agent. A router with only select is accepted; if that hook ever misses at run time (with no decider to fall back to), .delegate() fails with a clear "no decider is configured" error instead of silently trying to run one.

NAMEOF

nameof(Name) returns a declared top-level name (agent, router, memory, tool, prompt, skill, pipeline/workflow, extension, type, enum) as a string, validated to exist — the argument must be a bare identifier, not a string or an expression. Returning nameof(Billing) from select instead of the string literal "Billing" means a typo is a mhl lint error, and "go to definition" on Billing in an editor follows it to the real agent declaration. A non-null select return that still names no declared agent — a plain string typo, or a stale rename — always fails .delegate() immediately, listing the real agent names; it is never silently treated as "select declined to decide".

ALPHA

An agents: entry naming an undeclared agent, or a decision call whose answer matches none of them, is also an error — at mhl lint time for the former, at .delegate() call time for the latter.

07prompt

Executable

A named template with parameters. A prompt renders to a string and can be passed to an agent or composed into another prompt.

INLINE AND COMPOSED

prompt Role() {
    "You are a software security reviewer."
}

prompt Review(role: prompt, file: string, code: string) {
    """
    ${role}

    Review ${file}. Return concrete risks and a minimal fix.

    ${code}
    """
}

var request = Review(
    role: Role(),
    file: "auth.go",
    code: fs.read("auth.go")
)

MARKDOWN BODY

review.prompt.md
# Review of ${file}

## Criteria
- correctness
- security
- compatibility

## Code
${code}
prompts.mh
prompt Review(
    file: string,
    code: string
) from "review.prompt.md"

var rendered = Review(
    file: "main.go",
    code: fs.read("main.go")
)
  • Prompt arguments are named: Review(file: "x", code: source).
  • Missing, extra, or unknown placeholders fail instead of producing incomplete text.
  • A parameter may declare a default — prompt Review(file: string, lang: string = "en") — used when the caller omits that named argument. Because prompt binding is by name, defaulted parameters may appear in any position, and a default expression is evaluated in the caller's scope (it cannot reference a sibling parameter).
  • Use \${NAME} in external Markdown when a placeholder must remain literal.

FRONTMATTER (FILE-SOURCED ONLY)

review.prompt.md
---
version: 2
---
# Review of ${file}
${code}
prompts.mh
prompt Review(file: string, code: string) from "review.prompt.md"

log(Review.frontmatter.version)     // 2
log(Review.frontmatter["version"])  // 2 — same value, dynamic key

A from "..." file may optionally open with a flat ----delimited frontmatter block — the same shape a skill's SKILL.md uses, but with no required keys, since it carries no runtime meaning of its own here. It is stripped out of the rendered body and reachable as PromptName.frontmatter, an ordinary object. A file with no such block behaves exactly as before this existed — frontmatter is simply an empty object. An inline prompt Name() { "..." } body never has frontmatter.

08skill

Executable

Declares a reusable instruction package loaded from a Markdown file with frontmatter — the mhl counterpart of Claude's Agent Skills. Always file-sourced; there is no inline skill Name { ... } form. Consumed through an agent's skills: allow-list and system_prompt hook — see §05 agent · SKILLS.

code-review.md
---
name: code-review
description: Review a diff for correctness issues
---
Look for null dereferences and off-by-one errors.
reviewer.mh
export skill CodeReview from "code-review.md"
PieceExampleBehavior
MHL nameCodeReviewThe declaration identifier — how the program refers to it (skills: [CodeReview], nameof(CodeReview)). Distinct from the frontmatter's own name, which identifies the package in text sent to a model, not in the program.
from "path""./skills/code-review/SKILL.md"Resolved relative to the file that declared it — including when the declaration was reached through an import, exactly like prompt ... from.
Frontmattername: ... · description: ...name and description are required; any other flat key is passed through unchanged. Not full YAML — no nesting, no lists, no multi-line values.
ContentLook for null dereferences...The Markdown body with the frontmatter block stripped out.
  • Importable and exportable like any other named declaration: export skill CodeReview from "...", import {CodeReview} from "skills.mh".
  • A missing file, a frontmatter block with no name/description, or a file over the size limit all fail at import resolution — before mhl run ever reaches a step that uses the skill.
  • Nothing in a skill's Markdown is ever executed. Any effect an agent needs still goes through a before hook calling a declared tool/extension — a skill is instructions only.
WHY A HOOK, NOT A PLACEHOLDER

There is no "${skills}" placeholder token for args: composing instructions into the outgoing request is always explicit, through an agent's system_prompt: (skills, prompt) -> ... hook, so it's visible in the declaration exactly how — and whether — a selected skill's text actually reaches the model.

schema

Executable

Declares a JSON Schema file an agent's structured output must follow. Used as a value, the name is an object with the file's JSON text and its absolute path — the two shapes agent CLIs take a schema in (claude --json-schema '<json>' inline, codex --output-schema <file> by path).

schemas.mh
export schema Brief from "schemas/brief.schema.json"
main.mh
import { Brief } from "schemas.mh"

var inline = Claude.run(prompt: p, schema: Brief.content)
var by_file = Codex.run(prompt: p, schema: Brief.path)
PieceExampleBehavior
from "path""schemas/brief.schema.json"Resolved relative to the file that declared it — including through an import, exactly like prompt ... from.
Name.content"{\"type\": \"object\", ...}"The file's JSON text, unchanged.
Name.path"/home/me/proj/schemas/brief.schema.json"The file's absolute path. A subprocess resolves a relative path against its own working directory — the mhl process's, not the .mh file's — so an absolute one works wherever mhl is started from.
  • The value is a plain object {content: string, path: string}: it passes wherever a parameter of that shape is expected, and a local var of the same name shadows it.
  • A missing file, invalid JSON, a document that is not a JSON object, or a file over 1 MiB fails at import resolution — reported by mhl lint and before mhl run reaches any step.
  • Importable, exportable, and valid in nameof(Brief) like any other named declaration.
  • The checkpoint digest covers the schema's content, not its path: editing the schema invalidates --resume, moving the project does not.

09memory and mem

Executable

memory declares a named store. mem creates a pipeline-scoped persistent variable, ideal for a loop pipeline stop condition.

NAMED MEMORY

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

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

Session.set("attempt", 1)
Session.set({status: "running", owner: "platform"})
var attempt = Session.get("attempt", 0)
Session.remove("owner")
Audit.append("pipeline started")
TypeConfigurationOperations
In-memory KVtype: "kv" · store: "memory"set, get; lasts one execution. No remove.
JSONtype: "json" · path: "state.json"set, get, remove; persists to disk.
Text logtype: "append_log" · path: "audit.log"append; one text line per call.
JSONLtype: "jsonl" · path: "events.jsonl"append; writes {value, ts} as one JSON line.
PATHS

A store's path: is interpolated for ${...} spans the same way an agent's log: path is — path: ".mhl/state.${context.session_id}.json" gives every run (and every --session) its own file, so concurrent runs never share a store. context.session_id and the other run-metadata fields are available with no context: block, exactly like in log:. Within JSON memory, Session.get("config::retry") navigates nested objects and remove("config::retry") removes only that field.

PATH TRAVERSAL

Interpolation does not sanitize by default: path: "projects/${project_id}/f.json" with a caller-controlled project_id of "../../../../tmp/x" really does write there. Add path_guard: "no_traversal" to fail closed instead — it checks each ${...} span's own value (never the whole templated path, which may legitimately start absolute) for .. or an absolute shape, before anything touches disk. Opt-in, not the default; any other path_guard value is a build-time error.

PERSISTENT PIPELINE VARIABLE

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"
        log.info("attempt", attempts, status)
    }
}
  • mem name = value uses get-or-init semantics: the initializer runs only when no value exists.
  • The value survives between iterations and is visible to repeat.stop_when.
  • name.reset() deletes the persisted value; the next access initializes it again.

10tool and native operations

Executable

A tool creates a small, reusable API over native operations. Declare const or var in the tool body for values shared by its methods during a call. Methods take positional arguments, may declare types, and use either an expression body (name(p): T -> expression) or a statement block (name(p): T { ... }). The -> is needed only before an expression; before a block it is optional, since the { already opens the body.

DEFAULT ARGUMENTS

A method or lambda parameter may end with = expression — write(path: string, mode: string = "0644") — supplying a value when the caller omits that argument. Positional parameters bind left to right, so a default may read an earlier parameter (close: string = open) but a non-defaulted parameter may not follow a defaulted one (mhl lint flags it). Each default is evaluated in the callee's own scope, once per call that omits it.

SELF

Inside one method, self.other_method(...) calls a sibling method of the same tool — useful for composing small, private helpers into a public one (changed_go_files() above could read self.git_root(), say). It resolves to this tool regardless of what else is declared elsewhere, so renaming the tool never breaks its own internal calls. self means something different — a pipeline/workflow's own state — inside a step; see SELF INSIDE A STEP. Using either self. form in the wrong place (a tool method calling self.some_property, or a step outside any pipeline) is a mhl lint error, not a silent no-op.

INTERNAL METHODS

Prefix a method with internal to keep it private to its tool: internal clean(id: string): string -> id.trim(). It is callable as self.clean(...) from the tool's own methods, and from the tests of the file that declares the tool (so a helper stays testable); any other caller — a step, a hook, another tool, a test in another file — is a mhl lint error and fails at run time. Tool. completion does not offer it outside the tool.

tool Project {
    const test_timeout = 2m
    var directory = "."
    read(path: string): string -> fs.read(path)

    test(package: string): {stdout: string, stderr: string, exit_code: number} ->
        cmd.exec(["go", "test", package], timeout: test_timeout)

    changed_go_files(): string[] {
        var paths = fs.list(directory)
        return paths.filter((path) -> path.ends_with(".go"))
    }
}

var source = Project.read("main.go")
var result = Project.test("./...")
if (result.exit_code != 0) fail(result.stderr)

AVAILABLE OPERATIONS

cmd

exec(command_or_argv, timeout:, encoding:)

exec_all(commands:, timeout:, encoding:)

fs

read · exists · write · append

delete · join · list

dir

create · exists · list · delete

Directory-specific, non-recursive deletion.

git

diff · add · commit · status

rev_parse · log

json

parse · parse_lines · stringify

http

get · post · put · patch · delete · head · options · download

(url:, headers:, query:, body:, text:, form:, timeout:, follow_redirects:, raise_for_status:, auth:, tls:, proxy:)

log

info · warn · error

time

now() · parse(text, layout:) · format(value, layout:)

add(value, duration) · diff(a, b) · compare(a, b) · sleep(duration)

uuid

v4() · v7()

Canonical 36-character lower-case UUIDs; v7 is time-ordered, so its values sort in creation order.

html

parse(text) · get_element(s)(node, tag:, attrs:) · get_element_by_id(node, id)

get_attribute(element, name, default:) · get_text(node) · to_html(node)

Parses into plain {tag, attrs, text, children} objects (no separate element type); no implicit <html>/<head>/<body> wrapping.

os

user() · home_dir() · hostname()

platform() · arch() · cwd() · pid() · executable()

Facts about the host running the mhl process, not about the caller.

SIGNATURES

Full parameter names, types, and return shapes for every native operation are in the specification’s native-operations clause.

var result = cmd.exec(["git", "status", "--short"], timeout: 30s)
var response = http.post(
    url: "https://api.example.com/jobs",
    headers: {"Authorization": "Bearer " + env("API_TOKEN")},
    body: {status: "ready"}
)
var jobs = http.get(
    url: "https://api.example.com/jobs",
    query: {"state": "ready", "limit": "50"},
    tls: {cert: "client.pem", key: "client.key"},   // mutual TLS
    proxy: "http://corp-proxy:3128"                  // else HTTP(S)_PROXY is used
)
if (jobs.ok) fs.write("result.json", json.stringify(jobs.json))
http.download("https://api.example.com/artifacts/build.zip", "build.zip")
dir.create("artifacts/reports")                 // idempotent; creates parents
var entries = dir.list("artifacts")             // immediate, sorted paths
dir.clear("artifacts/adr")                      // exists and is empty afterwards
if (dir.exists("artifacts/reports")) dir.delete("artifacts/reports") // empty only
var expires_at = time.add(time.now(), 7d)

dir is the directory-specific companion to fs. dir.create(path) has mkdir -p semantics and succeeds when the directory already exists. dir.exists(path) returns true only for a directory (an existing file returns false). dir.list(path) returns immediate entries as joined paths in filename order. dir.delete(path) removes only an empty directory and raises for a file, missing path, or non-empty directory; it never recursively deletes contents. dir.clear(path) is the one recursive primitive, for regenerating a whole collection: afterwards path exists and is empty — created when absent, everything inside removed when present (a symlink inside is removed itself, never followed). It refuses an empty path, a filesystem root, the working directory or one of its ancestors, and a symlinked path.

var legacy = fs.read("export.csv", encoding: "windows-1252") // decoded to UTF-8
var notes  = fs.read("notes.txt", "auto")                     // BOM / UTF-8 / Windows-1252
fs.write("report.csv", rendered, encoding: "utf-8-bom")      // Excel-friendly
var out = cmd.exec(["tool.exe"], encoding: "ibm850")          // legacy console code page

Strings are always UTF-8 inside mhl. fs.read/fs.write/fs.append and cmd.exec/cmd.exec_all take an optional encoding that transcodes at the boundary: "utf-8" (strict), "utf-8-bom", "utf-16"/"utf-16le"/"utf-16be", "latin1", "windows-1252", any WHATWG or IANA charset name, and — for reading only — "auto". Without it, bytes pass through untouched; read a legacy file with its real encoding before handing it to an agent's stdin:, since strict CLIs reject non-UTF-8 input. See the specification for the exact rules.

time.parse/time.format's layout accepts either a raw Go reference-time layout ("2006-01-02") or the friendlier yyyy/MM/dd/HH/mm/ss tokens (e.g. time.format(dt, "dd/MM/yyyy")) — case matters where a unit would otherwise be ambiguous: MM is always month, mm is always minute; HH is always 24-hour, hh is always 12-hour.

var target = input.repo_dir                       // some checkout, not this one

if (git.status(dir: target).exit_code != 0) fail("${target} is not a git repository")

var diff = git.diff("HEAD", dir: target)
if (!diff.is_empty()) {
    git.add(["src/", ":(exclude).harness"], dir: target)
    var committed = git.commit("chore: sync generated files", dir: target)
    if (committed.exit_code != 0) fail(committed.stderr)
}
var sha = git.rev_parse("HEAD", dir: target)      // short hash, e.g. "0661a80"
var recent = git.log(5, dir: target)             // --oneline text, newest first

git operations run git in the interpreter's working directory — or in a dir: argument when given (every git.* op accepts one; it maps to git -C <dir>), so a pipeline can act on a checkout other than its own. status, add and commit return the same inspectable {stdout, stderr, exit_code} shape as cmd.exec — a non-zero exit is a value to check, not a raised error — while diff, log and rev_parse return plain text and fail closed, raising on a bad ref. add requires at least one path, commit a non-empty message, log a positive count, and rev_parse a non-empty ref; each is validated before git is invoked. commit's message is passed as its own argument, never shell-quoted, so spaces survive intact; diff takes an optional target ref, and status/commit an optional trailing paths filter.

!
Prefer argv.

cmd.exec(["program", "arg with spaces"]) preserves every argument and avoids accidental shell interpolation. Use a command string only when shell features are actually required.

log.info("running as", os.user(), "on", os.hostname())
var cache_dir = fs.join(os.home_dir(), ".cache", "myapp")
dir.create(cache_dir)
if (os.platform() == "windows") { /* ... */ }
!
Host, not caller.

os.* reports the machine running the mhl process. Under mhl serve mcp/mhl serve a2a that's the server's host — folding os.user() or os.hostname() into a prompt sent to an agent leaks process details, not the identity of whoever called the tool.

11extension

Executable

extension <kind> <Name> { ...props } declares a use of a runtime capability provider. The core sees only a kind, a name, and a property bag; the adapter registered for that kind validates the properties and defines the methods you call on <Name>. Two kinds ship built in — mcp and a2a, detailed below. Any other kind resolves through an installed external adapter.

KindBacksWhere
extension mcpa Model Context Protocol serverbuilt in — §10
extension a2aa remote Agent2Agent endpointbuilt in — §11
extension <other>a custom capabilityexternal adapter

Several adapters of one kind — from "<id>"

When more than one installed adapter serves the same kind, pick one per declaration by its manifest id. The kind stays the contract (properties and methods); the id picks the implementation. Without from, a kind served by two installed adapters fails as ambiguous, and mhl extension doctor notes every such kind.

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

Writing an adapter for a new kind — its manifest, the JSON-RPC process protocol, permissions, secrets, and mhl extension install — is covered by the extension guide, not this page.

12extension mcp

Executable

Declares a Model Context Protocol server connection. Configuration supports stdio or HTTP transports and secure environment references. Call it with Server.call("toolName", { ... }), discover its catalog with Server.list_tools(), or inspect it with Server.discover().

stdio

extension mcp LocalFiles {
    transport: "stdio"
    command: "npx"
    args: [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "."
    ]
}

HTTP

extension mcp IssueTracker {
    transport: "http"
    url: "https://mcp.example.com"
    headers: {
        "Authorization":
            "Bearer " + env("MCP_TOKEN")
    }
}

var result = IssueTracker.call("search_issues", { query: "is:open" })
var catalog = IssueTracker.list_tools()
var info = IssueTracker.discover()
  • transport: "stdio" uses command and args; each call spawns a fresh process.
  • transport: "http" uses url and headers; each call is a fresh, independent request.
  • env("NAME") resolves a credential only when needed and keeps it out of source.
  • .call(toolName, arguments) issues a JSON-RPC tools/call; .list_tools() issues tools/list; .discover() issues server/discover (supported versions/capabilities/identity), or, against a handshake server, returns the initialize result's identity instead.
  • protocol: selects the wire protocol: "auto" (default), "2026-07-28" (stateless only), "2025-11-25" or "2025-06-18" (standard handshake, advertising that revision). Any other value is a build-time error.
  • In "auto" mode the stateless 2026-07-28 form is tried first; if the server rejects it with a protocol-incompatibility error (JSON-RPC -32602/-32600, or HTTP 400) the call is retried over the standard initialize / notifications/initialized handshake (advertising 2025-11-25, _meta dropped, Mcp-Session-Id threaded through), letting the server negotiate the version down. If both fail, the error names both attempts.
  • On http, .call(...) first checks the target tool's schema for x-mcp-header-annotated arguments (top-level, string/integer/boolean only) and mirrors any present into Mcp-Param-{Name} headers — best-effort: if the schema can't be fetched, the call proceeds without them.
  • A server response with resultType: "input_required" (Multi Round-Trip Requests — the server wants elicitation/sampling input) is rejected with a clear error; this client cannot gather and resubmit that input.
PROTOCOL

A stateless 2026-07-28 call keeps no session between calls — each is fully independent, self-identifying with its protocol version, capabilities, and a {name: "mhl", version} clientInfo. "auto" (the default) may fall back to a short initialize/notifications/initialized handshake for a server on the 2025-11-25 or 2025-06-18 revisions; that handshake session lives only for the one call. A credential that fails to resolve aborts the call instead of sending a request with an empty header.

13extension a2a

Executable

Declares a stateless connection to a remote agent that speaks the Agent2Agent (A2A) protocol, revision 0.2.x: JSON-RPC 2.0 over HTTP(S) with header-based auth. Send it a message with Agent.send("..."), read its published capabilities with Agent.agent_card(), and poll or abort a running task with Agent.get_task(id) / Agent.cancel(id).

Declaration

extension a2a Translator {
    url: "https://translator.example.com/a2a"
    headers: {
        "Authorization":
            "Bearer " + env("A2A_TOKEN")
    }
    poll_interval: 1s     // optional, default 1s
    poll_timeout: 120s    // optional, default 120s
}

Use

var reply = Translator.send("Translate 'hello' to French")
log(reply.text)                       // -> "bonjour"

var card = Translator.agent_card()    // /.well-known/agent-card.json
var again = Translator.send("and to German?", context: reply.context_id)

var status = Translator.get_task(reply.task_id)
var stopped = Translator.cancel(reply.task_id)
  • url is the remote agent's JSON-RPC endpoint; headers carry auth, with env("NAME") resolving a credential only when needed and keeping it out of source.
  • .send(message, context?) issues message/send with the string as a single text part. If the reply is a Task that is not yet in a terminal state, it polls tasks/get every poll_interval until the task finishes or poll_timeout elapses. context: sets contextId for a follow-up turn.
  • The result is a normalized object: a message is {kind: "message", text, parts, message_id, context_id}; a task is {kind: "task", task_id, context_id, state, text, artifacts, history, status}. text is the concatenation of every text part — a convenience; the raw parts/artifacts are still there.
  • .agent_card() GETs <origin>/.well-known/agent-card.json. .get_task(id, history_length?) issues tasks/get; .cancel(id) issues tasks/cancel.
  • A task that stops in input-required or auth-required is rejected with a clear error — mhl's A2A client cannot gather and resubmit that input.
STATELESS

Every method is one or more independent requests with no session between them; a blocking .send that polls keeps no state beyond the task id. A credential that fails to resolve aborts the call before any request. Streaming (message/stream) and push notifications are not implemented yet.

14pipeline and workflow

Executable

The executable unit. Inputs arrive through the CLI, pipeline variables are shared across steps, and steps run in declared order. pipeline keeps that order — goto is a lint error. workflow is the same in every other way but allows goto <step>, forward or backward, so its steps form an explicitly branching state machine. Either takes the loop prefix.

pipelineworkflow
Steps in declared order, each onceyesyes
parallel, spawn/wait, loop + repeat:yesyes
goto <step> / goto matchlint erroryes — forward or backward
Reading guaranteeruns top to bottommay branch — read the goto's

RECOVERABLE WORKFLOW

workflow Release {
    input environment: string
    var approved = false

    // No `checkpoint` block needed — per-step checkpointing is on by
    // default, so `--resume` already works. Add one only to tune it:
    //   checkpoint: { ttl: 30d }        // keep abandoned state longer
    //   checkpoint: { enabled: false }  // opt out entirely

    step Validate {
        var result = cmd.exec(["go", "test", "./..."], timeout: 2m)
        if (result.exit_code != 0) fail(result.stderr)
        approved = true
    }

    step Publish {
        if (!approved) goto Validate       // backward jump — needs `workflow`
        log.info("publishing to", environment)
    }
}

STATIC STEP DISPATCH

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

goto match evaluates artifact once and selects the first matching arm. To reuse that table, declare route Generate(artifact: string) { ... } in the workflow and call it with goto Generate(artifact) from any step. A route has one typed parameter and static step destinations; mhl lint checks its name, arity, patterns, and targets. It transfers control directly rather than returning a string. Expression nameof(BriefGenerate) cannot name a workflow step because that builtin checks top-level declarations. On --resume, a paused gate runs again with updated inputs and selects the route then. See sample/syntax/pipeline_vs_workflow/goto_match_artifact_review.mh.

SPLITTING ACROSS FILES

A large workflow — many artifact types, each its own Generate/Commit pair — can outgrow one file. partial splits the declaration, not the pipeline: every fragment sharing a name and the same pipeline/workflow keyword is combined into one declaration before it ever runs, exactly like C#'s partial class. A fragment is pulled in with the bare, brace-less form of import — import "file.mh" — which merges every declaration in that file, not just named/exported ones; that's what lets a fragment join without itself being exported. Because steps and pipeline-scoped vars are looked up by name, a goto in one fragment reaches a step declared in another with no extra wiring.

discovery.mh
import "discovery.brief.mh"

partial workflow Discovery {
    var pending_data = null

    entry step Dispatch {
        goto BriefGenerate
    }
}
discovery.brief.mh
partial workflow Discovery {
    step BriefGenerate {
        pending_data = { title: "brief" }
        log(pending_data)
    }
}

Because a pipeline otherwise starts at its first physically declared step, and "first" stops meaning anything once steps arrive from more than one file, a partial declaration must mark its real starting point explicitly: exactly one step, anywhere among its fragments, carries the entry keyword — entry step Dispatch { ... } above. entry is rejected outside a partial declaration, where the first step is unambiguous already.

SELF INSIDE A STEP

Inside a step, self.name reads a declared input/var/mem of that step's own pipeline, or writes a var/mem (inputs are read-only) — self.pending_data = json.parse(raw) reads exactly like pending_data = json.parse(raw) does today, and both keep working side by side. The difference is what self. makes visible at the point of use: a bare name gives no hint whether it's this step's own local var or shared pipeline state declared somewhere else entirely (in a partial pipeline, possibly a completely different file) — self. always means the latter, unambiguously, and it's what an editor's completion can act on: typing self. inside a step lists every input/var/mem the pipeline declares, gathered across every merged partial fragment, not just the file currently open. It also settles a real edge case cleanly: if a step-local var happens to share a name with a pipeline-level one, the bare name reads the local shadow (as it always has) while self.name always reaches past it to the pipeline's own binding.

USE COMPLETE() FOR A CONVERGENCE STEP

A step with no goto at the end of its body isn't a problem by itself — mhl's execution model defaults to sequential fallthrough everywhere (a plain pipeline has no goto at all), so it's completely normal for a step to just flow into the next one physically declared after it. It only becomes a footgun for one specific kind of step: a shared convergence point every branch gotos to, meant to end the run — its safety depended entirely on happening to be the physically last step, which stops being obvious once a workflow is split across files (the merged order follows import order, not any one file's own layout). Give that step a body of complete() instead of leaving it empty: step Done { complete() } ends the run in the normal "completed" state right there, regardless of where the merge puts it — no more reasoning about which fragment gets imported last. This is not a hypothetical: an empty convergence step silently ended up mid-plan after a real split, and a completed run continued into the next artifact's generation step, firing an unwanted LLM call — complete() exists specifically so that can't happen again, not just so mhl lint catches it after the fact (which it still does — see FALLTHROUGH ACROSS FRAGMENTS below — for a step that hasn't been converted yet).

FALLTHROUGH ACROSS FRAGMENTS

mhl lint checks one specific seam: a fragment's own last step, when another fragment is still merged in after it, must end in an unconditional goto/goto match/fail(...)/pause(...)/complete()/break (or a trailing if/else where both branches do) — otherwise it silently falls through into a step in a different file. A step that isn't its fragment's last member is never checked — reordering steps inside one file is exactly as visible as it always was, and sequential fallthrough there is completely normal, not flagged. Prefer fixing a real terminal step with complete() (above) over just satisfying this check with a goto to nowhere useful.

LONE FRAGMENT

A fragment file linted or edited on its own — its siblings not pulled in by anything in that file — stays quiet about being incomplete, since that's the expected shape, not a bug in the fragment: a missing entry step, a goto/route target it can't see, and a step assigning to a pipeline-scoped var declared only in a sibling are all withheld the same way, and for the same reason — this one file genuinely cannot tell "the rest is elsewhere" from "actually wrong." Once a whole-file import somewhere has genuinely merged more than one fragment together, all of that comes back: a still-missing entry, a target that still doesn't resolve, a variable that's still nowhere declared. Either way, mhl run (via runtime.FindPipeline, and a real undefined-variable error at the step that reads it) always catches a truly broken pipeline the moment something tries to actually run the assembled result, lint or no lint. A directory-wide scan (mhl serve mcp <dir>, mhl test <dir>) skips registering an incomplete fragment rather than failing the whole directory over it.

FACTOR THE COMMIT SIDE OF A GENERATE/COMMIT PAIR

A Generate/Commit pair repeated once per artifact type (brief, requisitos, adr, ...) — and, worse, repeated again across sibling workflows that each generate a similar set of artifacts — tends to get copy-pasted wholesale, including the half of it that has nothing to do with control flow. Only Generate's final goto and Commit's own final goto Done actually need to live in the step body — goto/pause()/complete() are valid only inside a step, never inside a tool method, so a step can't be replaced outright, but everything each Commit step does before its own goto — building a rendered page, writing the artifact's JSON and HTML, assembling the result object — carries no control flow of its own and is exactly the part a shared tool can do once, called identically from every sibling workflow's own Commit step:

// shared/artifacts/artifact_commit.mh — one tool, reused from every
// workflow's own *Commit step; only the trailing goto stays there.
export tool ArtifactCommit {
    finish(project_id: string, relative: string, title: string, body: string, data: any, tokens_in: number, tokens_out: number): any -> {
        var rendered = PageShell(title: title, body: body, meta: ArtifactMeta.line(time.format(time.now(), "yyyy-MM-dd HH:mm"), "artifacts/" + relative, tokens_in, tokens_out))
        ArtifactData.write(project_id, relative, data)
        fs.write(Paths.artifact(project_id, relative), rendered)
        return { path: "artifacts/" + relative }
    }
}

// Each workflow's own Commit step shrinks to the one thing that's actually
// specific to it (which body renderer, which title) plus its goto:
step BriefCommit {
    discovery_result = ArtifactCommit.finish(project_id, "brief.html", "Brief", ArtifactBody.brief(pending_data), pending_data, tokens_in, tokens_out)
    goto Done
}
startmhl run release.mh --input environment=staging
resumemhl run release.mh --input environment=staging --resume
pinmhl run release.mh --session nightly-42 --resume
CHECKPOINT

The checkpoint: { enabled, strategy, storage, ttl } block is optional. Every pipeline/workflow is projected with enabled: true, strategy: "per_step" unless it says otherwise, so an interrupted run is always resumable with mhl run --resume / run/resume and a completed run clears its own state. Declare the block only to override a field — checkpoint: { ttl: 30d } keeps the default strategy and just changes retention; checkpoint: { enabled: false } turns checkpointing off for a workflow whose steps aren't safe to half-replay. strategy has one value today ("per_step") and storage one ("file").

SESSIONS

Every run gets its own directory .mhl/state/<session-id>/, so two concurrent runs of the same pipeline never clobber each other's checkpoint. Each run prints a session: <id> line; a bare --resume follows the most recent one (via a .mhl/state/<pipeline>.latest pointer), and --session <id> pins an exact one. Abandoned session directories past their TTL are swept automatically at the start of a later run.

DEFINITION BINDING

Every checkpoint records a digest of the pipeline it was written for — that pipeline's own body plus the shared declarations it can reference (agents, tools, prompts, memory, extensions, types, imports); comments and formatting are excluded, and editing a sibling pipeline or a test block in the same file does not affect it. A --resume whose current digest differs is refused with a definition-mismatch error rather than splicing the saved variable state into changed control flow: restore the original definition, re-run without --resume, or — if you know the edit is safe to resume through — pass --resume --force to downgrade the check to a warning. A checkpoint written by an older mhl that predates this field still resumes.

WHAT PERSISTS

Checkpoint state is JSON. A pipeline-scoped var survives --resume only if it holds a string, number, bool, null, or an array/object of those; a resolved secret is stored as its re-resolvable reference (env("…")) and re-read on resume. Assigning something JSON cannot represent — a closure, a handle — to a pipeline-scoped var fails the run at the next checkpoint with a clear message rather than being silently dropped to {}; keep closures in step-local vars (which never cross a checkpoint), or set checkpoint: { enabled: false }.

INPUTS ARE READ-ONLY

An input is what the caller passed, re-bound before every step and on every --resume/run/resume (with whatever arguments the resume merged in). Assigning to one — x = …, x += …, self.x = …, a destructuring target — or redeclaring it with var is an error at mhl lint and mhl run, exactly like the typed signature's parameter. Copy it into a var/mem for anything that changes during the run: a pipeline-scoped var keeps what was last assigned across goto, pause() and resume. (Before inputs were read-only, such a write was silently undone at the next step — a gate that "cleared" an input to leave a goto loop re-entered it forever.)

WORKFLOW ALIAS

When two entry points run the same flow and differ only in a few inputs, write the flow once and publish each entry point as an alias: workflow Delivery = ArtifactFlow with { level: Level.delivery } { description: "..." }. An alias runs its target's steps unchanged, but is its own entry point everywhere a workflow is one — its own MCP tool / A2A skill (its description, or the target's when it sets none), its own mhl run target and Delivery.run(inputs: ...) in tests, its own sessions, checkpoints and mem state. Its input contract is the target's minus the bound inputs: they are removed from the inputSchema, a caller passing one is rejected as an undeclared input, and inside the steps they read exactly as if the caller had passed them (for a typed signature, as fields of its parameter object). A bound value is a literal or an enum value (Level.delivery, or its name "delivery") of the input's type. The target must be a pipeline/workflow declaration of the same kind — not another alias — and the alias's own body may only set description; mhl lint checks all of it. Editing the target invalidates the alias's checkpoints. Under mhl serve, each declaration is published by the file that declares it, so a shared target imported by several alias files is published once.

INTERNAL WORKFLOWS

internal workflow ArtifactFlow { ... } declares a workflow that is not an entry point: mhl serve does not publish it and mhl run file.mh skips it (raising when it is the only one). It still runs as the target of a workflow alias — the usual reason to declare one: a shared flow published only through workflow Delivery = ArtifactFlow with { ... } — and through ArtifactFlow.run(inputs: ...) in tests. It combines with export (module visibility, a separate axis — a shared flow is typically export internal workflow), partial and loop, and also applies to an alias.

CREDENTIAL REFERENCES

Explicit credential resolution protects the exact nonempty value, including short or numeric strings and significant whitespace. Ordinary env() reads still use name/value heuristics; they do not declare every environment value a credential. Whole secret values with one known reference can be rehydrated; embedded or reference-free secrets are masked. If distinct references resolve to identical content in the same runtime process, string state cannot distinguish their origin: checkpoints retain an ambiguity marker and resume fails explicitly. Start a new run with distinct credentials instead of selecting either reference silently. Registration is process-wide, so an explicitly registered short value also masks matching text elsewhere in that process.

SIDE EFFECTS

A checkpoint records progress between steps, not the external effects a step performed. If a step creates a ticket, POSTs to an API, or runs a cmd, then fails — or the process dies — before its checkpoint is written, --resume re-runs that whole step and repeats the effect; a pause re-enters its step the same way, and a failed parallel branch re-runs alongside its already-completed siblings. mhl gives at-least-once execution per step, never exactly-once for an external system. Make a step that mutates the outside world idempotent: pass your own idempotency key to the API, check-then-act, or split prepare / approve / apply into separate steps so only the final, cheap apply can repeat. Give a repeated agent call a cache.

READING THE PREVIOUS RUN'S STATE

pipeline BuildCounter {
    context: {
        source: "latest"     // or "session:<id>"; default "latest"
        require: false        // true = fail the run when no prior state exists
    }

    var build = 1

    step Report {
        log.info("session ${context.session_id}, resumed=${context.resumed}")
        if (context.vars.keys().contains("build")) {
            build = context.vars["build"] + 1
        }
    }
}
CONTEXT

context is read-only and always available in every pipeline/workflow step — no declaration needed. context.session_id, context.started_at and context.resumed describe the current run; context.principal is the verified caller identity when the run was started through mhl serve mcp --http with a token verifier (--principal-header), and "" otherwise (a plain mhl run, or no verifier) — use it to record who triggered or approved a run, not to authorize. The context: { source, require } block is only needed for context.vars — the variable state the previous completed run of this pipeline finished with (persisted as result.json in that run's session directory). Without the block context.vars is {}; with it, it is still empty until a run has completed once — guard with context.vars.keys().contains(...).

EXPLICIT RESULT PROJECTION

workflow PublishRelease {
    input environment: string
    var artifact_url  = ""
    var internal_token = ""

    output: {
        url:         artifact_url,          // rename / pick
        environment: environment,           // any expression over the final state
    }

    step Ship {
        artifact_url  = "https://releases.example.com/" + environment
        internal_token = resolve_token()    // stays internal — not in `output:`
    }
}
OUTPUT

The optional output: { name: expr, … } block is the run's result contract. With it declared, a mhl run caller — and every mhl serve mcp / serve a2a client — receives only these keys, each evaluated against the final variable state (so a key can be renamed, computed, or dropped). Without it, the legacy behaviour returns every non-internal var. The mapping does not change what a later run's context: reads back — that still sees the full variable state. The value must be an object mapping; anything else fails the run.

OUTPUT & PAUSE

The mapping is evaluated once, only when the run finishes (a completion or a break). A run parked by pause(...) skips it — its expressions may read a var that step has not reached — and the suspension response returns the partial state; the run/resume (or mhl run --resume) that completes the run produces the real projection. The result is not checkpointed, so a resume re-evaluates it, and a mapping expression observes the run's cancellation/deadline — keep side effects out of it.

TYPED SIGNATURE

type Finding      = { file: string, message: string }
type ReviewInput  = { diff: string, base?: string }
type ReviewOutput = { approved: bool, findings: Finding[], summary: string }

workflow ReviewPullRequest(req: ReviewInput): ReviewOutput {
    var findings = []
    var summary  = ""

    step Review {
        var base = req.base ?? "main"            // optional field → null when omitted
        var review = json.parse(Reviewer.run(prompt: "Review against ${base}:\n${req.diff}"))
        findings = review.findings
        summary  = review.summary
    }

    output: {                                    // required alongside a result type
        approved: findings.size() == 0
        findings: findings
        summary:  summary
    }
}

A pipeline/workflow (and its loop form — loop workflow Refine(req: In): Out max 3 { ... }) may declare a signature in the same name(param): Type shape a tool method uses. Both halves are optional and independent; a declaration with neither behaves exactly as before.

PartRuleEffect
(req: In)In: object typeThe fields of In are the pipeline's inputs — for --input, tools/call arguments, and the inputSchema (a field?: is optional there). A step reads them as one object, req.diff, never as bare vars. It replaces per-line input x: T: mixing the two, or a pipeline-level var/const/mem named like the param, is a lint error. The param is read-only, like a const: req = …, self.req = …, req["x"] = … and a step-local var req are refused by mhl lint and at run time. To change a value, copy it into a var first (var base = req.base ?? "main").
: OutOut: object typeThe run's result type. An explicit output: { ... } is required alongside it — the result is never projected implicitly from same-named vars. When output: is an object literal, mhl lint checks its keys are exactly Out's fields (every required field present, none undeclared); either way the projected value is checked against Out when the run finishes — a normal finish or a break — and a mismatch fails it with an output_contract error (mhl run --format json kind) instead of returning a result that breaks the contract.
OVER MCP / A2A

A typed result is advertised as the tool's outputSchema in tools/list (titled with the type name), and a successful tools/call's structuredContent is the checked projection. The mhl://workflow/<name> manifest resource carries outputSchema too, plus input: {param, type} and output: {type} naming the declared types. Because the MCP spec requires structured results to conform to outputSchema, an error or a paused run of a typed tool is reported in the text content only, with no structuredContent; an untyped tool keeps its structured error object. An A2A skill carries the same schema as a non-standard metadata.outputSchema hint. An enum-typed field at any depth — inside an object field or array items, on input or output — is advertised with its "enum": [...] variant list.

RESUME

A run/resume that supplies some of the param's fields merges them onto the checkpointed req object; a key the pipeline does not declare (a pause decision) still binds as a top-level var. A lifecycle hook's session.inputs is the caller's arguments as sent — flat, keyed by field — not the bound req object. Adding a signature to an existing pipeline changes its definition digest, so a checkpoint from before the edit needs --resume --force; a pipeline that doesn't use one keeps the digest older builds wrote.

EDITOR

Inside a step, req. lists the param type's fields (an optional one marked as such) and self. lists the param. The header's type positions complete type names. Signature types in the header should be named aliases: an inline { ... } shape there is valid mhl, but the editor's block detection loses track of the declaration body after it.

LIFECYCLE HOOKS

Five optional body properties observe a run without taking part in it — a lambda of exactly one parameter, called for its side effects (log, mem, a declared tool/extension); its return value is ignored, and returning anything other than nothing at all is a runtime error. Each parameter is bound to one of three builtin, globally declared types — SessionContext, StepContext, FailureContext — the same vocabulary a string/number/bool annotation already resolves through, so session./step./failure. is a real, navigable member access: an editor lists that type's fields the moment you type the dot, with no annotation required (mhl lambdas stay unannotated everywhere else too). The parameter's name is whatever you write — session below is just a convention, not a keyword.

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

    step Dispatch {
        // Add workflow operations here.
    }
}
HookFiresParameter type
session_startonce, before the first step — including again on --resumeSessionContext: pipeline, kind, session_id, resumed, inputs (every field is always present; the ones this firing has nothing to say about are null)
session_endonce, on a normal completion or a break — never on pause() or a genuine failureSessionContext: vars, broke, break_reason (+ iterations on a loop) — same type as session_start, sharing one field set
step_startbefore every step — each parallel branch, each loop iterationStepContext: pipeline, step, index, total, error (error is always null here — the step hasn't run yet)
step_endafter every step, whether it succeeded or notStepContext, same shape — error is the failure message, or null on success/a caught control-flow exit
stop_failureonce, on a genuine terminal failureFailureContext: pipeline, kind, step, error, reason
REAL GLOBAL TYPES, NOT JUST HOOK METADATA

SessionContext/StepContext/FailureContext resolve anywhere a bare : Type name already does — a pipeline input, a tool method parameter or return type — the same way a user's own type X = {...} alias would (and, like a primitive keyword, a program cannot redeclare one of these three names). Declaring one there is purely optional and gets no special runtime enforcement beyond what any other declared object type already gets (structural, not exact — an object may carry extra fields); the hook's own lambda parameter is never annotated with it in practice, since the shape is already implied by which hook you're writing.

HOOK PARAMETER TYPE

If you do annotate a hook's parameter, it must be the type that hook is bound to — step_end: (ctx: SessionContext) -> ... is a mhl lint error. The runtime never checks the annotation, but an editor completes fields from it and would offer ones the bound value does not have.

STOP_FAILURE vs. A SOFT STOP

stop_failure's reason is one of "failed", "timeout", "cancelled", or "max_step_visits" (a goto cycle with no break to escape it) — always a genuine error. A loop exhausting its max_iterations ceiling is not one of these: it is a normal, non-error stop that fires session_end like any other completion, never stop_failure. The two names are one word apart; do not confuse a loop's iteration ceiling with the runaway-goto safety cap.

OBSERVATION ONLY, FAIL LOUD

A hook's own error — the wrong parameter count, a runtime error in its body, or a non-nil return — aborts the run exactly like any other failure, and (except inside stop_failure itself) is what a subsequent stop_failure then reports. An error raised by stop_failure is only ever logged alongside the failure that triggered it — it can never mask or replace the original cause. None of the five hooks can suppress, retry, or otherwise change what the run does; they exist to observe it.

LOOP WITH AN EXPLICIT STOP

loop pipeline PollBuild {
    mem status = "pending"
    mem checks = 0

    repeat: {
        stop_when: status == "passed"
        max_iterations: 20
    }

    step Check {
        checks = checks + 1
        var response = http.post(
            url: "https://ci.example.com/status",
            body: {attempt: checks}
        )
        status = json.parse(response.body).status
        if (status == "failed") break "build failed"
    }
}
MAX CLAUSE

When a fixed iteration ceiling is the whole repeat policy — no stop_when — write it as a max <N> header clause instead of a one-field repeat block: loop workflow Refine max 3 { … } is exactly repeat: { max_iterations: 3 }. The word max is reserved only in that position (after the declaration name), so a pipeline may still be named max. Declaring both the clause and an explicit repeat max_iterations is a mhl lint error — the repeat block wins — and max without the leading loop keyword is flagged as inert.

CONCURRENT SUB-AGENTS IN A STEP

pipeline Review {
    input diff: string
    input changed_files: string[]

    spawn: { max_concurrency: 4 }   // run-wide ceiling; default 4

    step Analyze {
        spawn rev   = Reviewer.run(prompt: "review:\n${diff}")
        spawn tests = Tester.run(prompt: "run the tests for:\n${diff}")
        spawn sec   = Security.run(prompt: "security pass:\n${diff}")

        wait rev, tests, sec timeout: 90s        // wait-all, fail-fast
        // wait any rev, tests, sec              // first success, cancels the rest
        // wait 2 of rev, tests, sec             // first N successes (quorum)
        // wait rev, tests on_error: "collect"   // never fails the step; read .ok / .error

        log(rev.result)
        if (!tests.ok) log("tests failed: ${tests.error}")
    }

    step FanOut {
        // one spawn per element: `item` is bound while each call's arguments
        // are built, so every prompt differs. `reviews` is an array of task
        // handles — `wait` joins it as a group; it indexes and iterates.
        spawn reviews = Reviewer.run(prompt: "review file ${item}") for item in changed_files
        wait reviews timeout: 90s                // or `wait any reviews` / `wait 2 of reviews`
        for (var r in reviews) log("${r.status}: ${r.result}")
    }
}

CONCURRENT STEPS (BARRIER)

pipeline Enrichment {
    input topic: string

    var docs   = ""
    var issues = ""

    parallel Gather timeout 2m {      // both steps run at once; barrier caps at 2m
        step FetchDocs timeout 45s { docs = Docs.run(prompt: "summarize ${topic}") }
        step FetchIssues           { issues = Issues.run(prompt: "open issues for ${topic}") }
    }

    step Merge {                      // runs only after BOTH branches finish
        log("docs   => ${docs}")
        log("issues => ${issues}")
    }
}
descriptionoptional "..." summary; the tool / skill description under mhl serve
inputa user-supplied value validated by type; optional = expr default makes it omittable — input item_type: string = "" — and drops it from the required set of the MCP/A2A inputSchema
varstate shared across steps in the current execution
mempersistent state shared across iterations and stop_when
contextread-only view of this session and the previous run's vars
stepordered, named, resumable unit; optional timeout <duration> header clause
parallelnamed group whose steps run concurrently; a barrier before the next step; takes the same timeout clause
gotojump to another step (workflow only), forward or backward
breakclean early stop with an optional reason; keeps the run's results (exit 0)
pausesuspend for a human-in-the-loop resume; keeps the checkpoint, re-enters this step on --resume
spawnstart an Agent.run(...) in the background; binds a task handle (.result, .ok, .error, .status, .duration_ms). A trailing for <item> in <array> fans out — one call per element, each with its own prompt — and binds an array of handles
waitjoin handles — all (fail-fast), any (first success), or N of (quorum); a fan-out array name expands to its elements; options timeout: and on_error: "collect"
LIMITS

For loops, always prefer an observable stop_when plus max_iterations as a safety ceiling. Step-local variables are not visible to stop_when; use mem or memory.

TIMEOUT

A step <Name> timeout <duration> { … } clause caps how long that step may run — step Build timeout 3m { … }. The runtime wraps the step's context with that deadline, so every blocking call it makes (an agent, http, cmd, an extension) is aborted when it elapses; the step then fails exactly as an explicit fail() would, so --resume / run/resume can still re-enter the step (unless the workflow set checkpoint: { enabled: false }) — with a fresh budget (the duration is per attempt, never persisted). The same clause on a parallel <Name> timeout <duration> { … } group bounds the whole barrier and cancels every still-running branch; a branch may also carry its own timeout, and the earliest deadline wins. A purely non-blocking runaway (a spinning while) is still bounded by max_iterations / the interpreter's loop cap, not by this.

SPAWN

spawn/wait are step-scoped: any handle not joined by a wait is joined automatically when the step ends, so nothing runs across a checkpoint. If the process dies mid-step, --resume re-runs the whole step and re-issues every spawn — keep them idempotent (or give the agent a cache). A spawned agent cannot itself spawn, and an agent's log: file is ignored under spawn (its output goes to the step log instead). The fan-out form spawn xs = Agent.run(...) for item in <array> starts one background call per element with item bound while that call's arguments are evaluated, and binds xs to an array of handles in element order — index it (xs[0].result), iterate it (for (var h in xs) …), take its .size(), and wait xs / wait any xs / wait N of xs. A non-array iterable is a runtime error; an empty array binds an empty handle array that a plain wait no-ops on. The run-wide max_concurrency still bounds how many are in flight, so fanning out over a large collection is safe.

PARALLEL

A parallel <Name> { … } group runs its steps concurrently and joins them all before the pipeline advances — the step-level counterpart of spawn (which parallelises agent calls within one step). Each branch gets its own copy of the pipeline vars; on join, the writes are merged back, and a var two branches set to different values fails the run — so the outcome is deterministic. A reference object is merged by identity, field by field: two branches changing different fields of one ref both land on the shared object; the same field set to different values fails the run. Branch output is buffered and printed in declared order. The group is atomic for checkpointing: a crash re-runs the whole group on --resume, so branch steps must stay idempotent. goto may not target a grouped step or be used from within one, and break may not be used from within one.

15test and describe

Executable

MHL files can be self-verifying examples. Each describe receives a fresh environment and uses the same statement syntax as a step.

tool Normalize {
    slug(text: string): string ->
        text.trim().to_lower().replace(" ", "_")
}

test normalize_examples {
    describe basic_slug {
        are_equal(Normalize.slug(" Feature Title "), "feature_title")
    }

    describe array_selection {
        var values = [1, 2, 3, 4]
        var even = values.filter((n) -> n % 2 == 0)
        are_equal(even, [2, 4])
        is_true(even.contains(4))
        is_false(even.contains(3))
    }

    describe future_behavior {
        incomplete("waiting for implementation")
    }
}
are_equalare_not_equalnot_equalis_trueis_falseis_nullnot_nullgreater_thanless_thangreater_than_or_equalless_than_or_equalincludesincomplete
filemhl test normalize.mh
directorymhl test examples/

RUNNING A 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) in an isolated, throwaway sandbox and returns the outcome as an object instead of raising, so a test can assert on a workflow expected to fail exactly as easily as one expected to complete.

pipeline GuardedRetry {
    step Implement {
        var attempts = 4
        if (attempts > 3) break "too many attempts"
    }
    step Handoff { var done = true }
}

test guarded_retry_test {
    describe stops_after_too_many_attempts {
        var result = GuardedRetry.run()
        is_true(result.ok)
        are_equal(result.state, "broke")
        are_equal(result.break_reason, "too many attempts")
        are_equal(result.executed, ["Implement"])
    }
}
FieldTypeMeaning
okboolfalse only when state is "failed"
statestring"completed" · "paused" · "broke" · "failed"
executedstring[]step names that ran, in order
varsobjectfinal state; {} when state is "failed" (except an output-contract failure: every step ran, so the state is kept)
outputobjectwhat a real caller receives — the output: projection, checked against a declared result type, or every var when there's no output:; null unless state is "completed" / "broke". A projection that breaks the result type reports state "failed" with the contract error
error / stepstringset only when state is "failed"
break_reason / pause_reasonanynull unless state is "broke" / "paused"
RAISES VS. REPORTS

fail()/break/pause(...) inside the workflow's own steps never raise — they land in the returned object. Only a problem with the call itself raises: a missing/mistyped required input, or a loop pipeline/loop workflow target (not supported yet). Calling .run() outside a describe block is refused — a run executing another synchronously mid-step has no defined interaction with checkpointing or context yet.

16CLI, diagnostics, and security

Executable

The mhl binary handles execution, tests, static analysis, and editor integration.

mhl initscaffold an immediately-runnable main.mh (never overwrites)
mhl run flow.mhrun the declared pipeline
mhl run flow.mh --input key=valuesupply typed inputs
mhl run flow.mh --resumeresume the most recent session's checkpoint (refused if the pipeline definition changed)
mhl run flow.mh --resume --forceresume through a changed definition — warn, don't refuse
mhl run flow.mh --session <id>pin an exact session directory (combine with --resume)
mhl run flow.mh --format jsonone machine-readable object: result on success, a typed {kind, step, hint} error on failure; paused and broken runs include pause_reason / break_reason; the run's own output goes to a log field; registered secrets are masked recursively in result values, object keys, stop reasons, and log/error text
mhl run flow.mh --dry-runvalidate (parse, imports, lint, input contract incl. declared value types) and print the static step plan — no step runs, no agent is called; combine with --format json
mhl test file.mhrun assertions
mhl test directory/discover tests recursively
mhl lint .validate syntax, types, and references
mhl lspstart the language server over stdio (used by the VS Code extension)
mhl pack dir/ -o workflows.dmpkseal a workflow directory into an encrypted pack for serve mcp --workflows-pack; key in MHL_SERVE_WORKFLOWS_KEY, or --new-key generates one and prints it as the only stdout line
mhl serve mcp dir/serve every workflow under dir/ as an MCP tool (stdio) — serving workflows
mhl serve mcp --http dir/the same over Streamable HTTP, plus async run/* — serving workflows
mhl serve a2a dir/serve them as Agent2Agent skills over HTTP — serving workflows
mhl versionprint the installed version
mhl extension init dir/scaffold an extension
mhl extension test dir/check the protocol
mhl extension package dir/capture declarations
mhl extension install dir/ · … <url>[//subdir][#ref]copy and pin an extension — from a local dir or a git remote
mhl extension listshow locked extensions
mhl extension doctorvalidate manifests and hashes
EXTENSIONS

This reference covers how to declare and call extension mcp / extension a2a (§10, §11). The extension guide covers external adapters and their JSON-RPC contract.

EDITOR

mhl lsp speaks LSP over stdin/stdout. It advertises three capabilities, all backed by the same static analysis mhl lint runs: diagnostics (published on open and on every change), completion (trigger character . — declaration keywords, the names declared in the file, the properties valid in the enclosing block, the type vocabulary in a : type position, and, after a ., that receiver's methods with their signature in the item detail), and signature help (trigger ( and ,), plus go-to-definition. There is no hover, formatting, rename, or workspace-symbol support yet. The VS Code extension in vscode-mhl/ is a thin vscode-languageclient wrapper around it — set mhl.serverPath if mhl is not on PATH, and use MHL: Restart Language Server after rebuilding the binary.

SERVE

The other direction — exposing your own workflows as MCP tools or A2A skills with mhl serve, including the async run/* methods, run/resume for human-in-the-loop, per-session ownership, and --state-dir — has its own page: serving workflows.

OPERATIONAL CHECKLIST

✓

Use env("TOKEN") for credential references where supported.

✓

Pass process arguments as an array; avoid concatenating untrusted input.

✓

Set a timeout, retry policy, and iteration limit for every external operation.

✓

Run mhl lint . and mhl test . before automating a flow.

✓

Review log, cache, and state files before sharing a workspace.

!
Principle of least privilege

A pipeline can run processes, write files, and access the network. Treat .mh files as code: review them before execution, limit available credentials, and run untrusted automation in an isolated environment.