Common
size()is_empty()equals()deep_equal()Strings, arrays, and objects. equals works on any value.
MHL
META-HARNESS LANGUAGE
MHL · SELF-CONTAINED REFERENCE · v1.4 RELEASE CANDIDATE
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.
Need to add a capability that is not built in? Read Extending the runtime for manifests, the process protocol, permissions, and installation.
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
export agent Echo {
command: "echo"
args: ["${prompt}"]
}
export prompt Greeting(name: string) {
"Hello, ${name}!"
}import {Echo, Greeting as Hello}
from "shared.mh"
pipeline Welcome {
input name: string
step Run {
log(Echo.run(
prompt: Hello(name: name)
))
}
}export prompt Name(...) { ... }Make a declaration reusable.import {A, B} from "file.mh"Bring in explicit contracts only.import {LongName as Short} from "file.mh"Avoid collisions and improve readability.import "file.mh"Merge every declaration, exported or not — see splitting a partial pipeline/workflow across files.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.
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" }
}
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
string · number · bool · anyInputs, parameters, returns, and contracts. int/integer/float spell number; boolean spells bool.string[] · number[][]Add [] for each level.{id: number, name: string}Structural shape with named fields.{id: number, tags: string[]}[]Arrays of typed objects.promptA 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)
}
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.
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
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.
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]").
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 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.
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)
}
}
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.
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
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
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.
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.
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
size()is_empty()equals()deep_equal()Strings, arrays, and objects. equals works on any value.
split()replace()contains()starts_with()ends_with()matches()trim()to_upper()to_lower()substring()remove()remove_content()extract_content()get_index()index_of()contains()filter()find()sort_by()map()reduce()any()all()append()join()unique()enumerate()keys()values()get()Keys are returned in stable order. get(key, default?) never raises on a missing field.
Every method's parameters, types, and return value are in the specification’s standard-library clause.
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(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)
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.
Statements perform actions inside a step, a tool method, a block lambda, or a test case.
var x = expressionCreates a local binding.const 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.x = expressionUpdates a variable, field, or index.var {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.x += 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 += ["..."].if (cond) { ... } else { ... }Executes a block by condition.while (cond) { ... }Repeats while the condition is true.for (var item in items) { ... }Iterates over an array.try { ... } catch (err) { ... } finally { ... }Handles recoverable failures.return valueExits a method or step.goto 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).goto 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.break "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.pause("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.complete()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")
}
}
}
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
agentDeclares 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)
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.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.
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.
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.
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.
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."
)
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..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.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."
)
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.mhl lint and mhl run.system_prompt runs once, before the primary attempt, never per retry and never again for a fallback leg.routerDeclares 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)
}
}
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.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(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(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.
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(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".
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.
promptA 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 of ${file}
## Criteria
- correctness
- security
- compatibility
## Code
${code}prompt Review(
file: string,
code: string
) from "review.prompt.md"
var rendered = Review(
file: "main.go",
code: fs.read("main.go")
)Review(file: "x", code: source).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).\${NAME} in external Markdown when a placeholder must remain literal.FRONTMATTER (FILE-SOURCED ONLY)
---
version: 2
---
# Review of ${file}
${code}prompt Review(file: string, code: string) from "review.prompt.md"
log(Review.frontmatter.version) // 2
log(Review.frontmatter["version"]) // 2 — same value, dynamic keyA 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.
skillDeclares 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.
---
name: code-review
description: Review a diff for correctness issues
---
Look for null dereferences and off-by-one errors.export skill CodeReview from "code-review.md"CodeReviewThe 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.name: ... · 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.Look for null dereferences...The Markdown body with the frontmatter block stripped out.export skill CodeReview from "...", import {CodeReview} from "skills.mh".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.before hook calling a declared tool/extension — a skill is instructions only.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.
schemaDeclares 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).
export schema Brief from "schemas/brief.schema.json"import { Brief } from "schemas.mh"
var inline = Claude.run(prompt: p, schema: Brief.content)
var by_file = Codex.run(prompt: p, schema: Brief.path)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.{content: string, path: string}: it passes wherever a parameter of that shape is expected, and a local var of the same name shadows it.mhl lint and before mhl run reaches any step.nameof(Brief) like any other named declaration.--resume, moving the project does not.memory and memmemory 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")
type: "kv" · store: "memory"set, get; lasts one execution. No remove.type: "json" · path: "state.json"set, get, remove; persists to disk.type: "append_log" · path: "audit.log"append; one text line per call.type: "jsonl" · path: "events.jsonl"append; writes {value, ts} as one JSON line.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.
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.repeat.stop_when.name.reset() deletes the persisted value; the next access initializes it again.tool and native operationsA 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.
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.
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.
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
cmdexec(command_or_argv, timeout:, encoding:)
exec_all(commands:, timeout:, encoding:)
fsread · exists · write · append
delete · join · list
dircreate · exists · list · delete
Directory-specific, non-recursive deletion.
gitdiff · add · commit · status
rev_parse · log
jsonparse · parse_lines · stringify
httpget · post · put · patch · delete · head · options · download
(url:, headers:, query:, body:, text:, form:, timeout:, follow_redirects:, raise_for_status:, auth:, tls:, proxy:)
loginfo · warn · error
timenow() · parse(text, layout:) · format(value, layout:)
add(value, duration) · diff(a, b) · compare(a, b) · sleep(duration)
uuidv4() · v7()
Canonical 36-character lower-case UUIDs; v7 is time-ordered, so its values sort in creation order.
htmlparse(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.
osuser() · home_dir() · hostname()
platform() · arch() · cwd() · pid() · executable()
Facts about the host running the mhl process, not about the caller.
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.
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") { /* ... */ }
extensionextension <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.
extension mcpa Model Context Protocol serverbuilt in — §10extension a2aa remote Agent2Agent endpointbuilt in — §11from "<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") }
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.
extension mcpDeclares 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().
extension mcp LocalFiles {
transport: "stdio"
command: "npx"
args: [
"-y",
"@modelcontextprotocol/server-filesystem",
"."
]
}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."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.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.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.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.
extension a2aDeclares 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).
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
}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.{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.input-required or auth-required is rejected with a clear error — mhl's A2A client cannot gather and resubmit that input.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.
pipeline and workflowThe 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.
pipelineworkflowyesyesparallel, spawn/wait, loop + repeat:yesyesgoto <step> / goto matchlint erroryes — forward or backwardruns top to bottommay branch — read the goto'sRECOVERABLE 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.
import "discovery.brief.mh"
partial workflow Discovery {
var pending_data = null
entry step Dispatch {
goto BriefGenerate
}
}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.
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.
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).
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.
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.
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
}
mhl run release.mh --input environment=stagingmhl run release.mh --input environment=staging --resumemhl run release.mh --session nightly-42 --resumeThe 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").
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.
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.
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 }.
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.)
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 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.
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.
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 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:`
}
}
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.
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.
(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.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.
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.
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.
}
}
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 setstep_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 exitstop_failureonce, on a genuine terminal failureFailureContext: pipeline, kind, step, error, reasonSessionContext/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.
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'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.
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"
}
}
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 serveinputa 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 inputSchemavarstate shared across steps in the current executionmempersistent state shared across iterations and stop_whencontextread-only view of this session and the previous run's varsstepordered, named, resumable unit; optional timeout <duration> header clauseparallelnamed group whose steps run concurrently; a barrier before the next step; takes the same timeout clausegotojump to another step (workflow only), forward or backwardbreakclean 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 --resumespawnstart 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 handleswaitjoin 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"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.
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/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.
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.
test and describeMHL 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
mhl test normalize.mhmhl 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"])
}
}
okboolfalse only when state is "failed"statestring"completed" · "paused" · "broke" · "failed"executedstring[]step names that ran, in ordervarsobjectfinal 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 errorerror / stepstringset only when state is "failed"break_reason / pause_reasonanynull unless state is "broke" / "paused"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.
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 pipelinemhl run flow.mh --input key=valuesupply typed inputsmhl 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 refusemhl 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 textmhl 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 jsonmhl test file.mhrun assertionsmhl test directory/discover tests recursivelymhl lint .validate syntax, types, and referencesmhl 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 linemhl serve mcp dir/serve every workflow under dir/ as an MCP tool (stdio) — serving workflowsmhl serve mcp --http dir/the same over Streamable HTTP, plus async run/* — serving workflowsmhl serve a2a dir/serve them as Agent2Agent skills over HTTP — serving workflowsmhl versionprint the installed versionmhl extension init dir/scaffold an extensionmhl extension test dir/check the protocolmhl extension package dir/capture declarationsmhl extension install dir/ · … <url>[//subdir][#ref]copy and pin an extension — from a local dir or a git remotemhl extension listshow locked extensionsmhl extension doctorvalidate manifests and hashesThis reference covers how to declare and call extension mcp / extension a2a (§10, §11). The extension guide covers external adapters and their JSON-RPC contract.
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.
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.