One long-lived process
The executable is not restarted for every method call. It remains alive for the current MHL run.
MHL
META-HARNESS LANGUAGE
MHL · RUNTIME EXTENSIONS · API V1
An external extension is a separate executable that MHL starts on demand and talks to over newline-delimited JSON-RPC. Build a CRM connector, database client, deployment tool, or any other capability in the language you prefer.
MCP and A2A are built-in extension kinds documented in the Language Reference. This page is for developers who want to add a new kind without changing MHL itself.
MHL owns orchestration and policy. Your executable owns the capability. The host starts one process on first use, reuses it for the run, multiplexes requests by id, and shuts it down at the end.
extension crm CustomerProperties and method arguments are evaluated by MHL.The executable is not restarted for every method call. It remains alive for the current MHL run.
Replies may arrive out of order. Correlate every response with the unsigned request id assigned by the sender.
Locking, permissions, secret resolution, redaction, and lifecycle enforcement are runtime responsibilities.
CORE / EXTENSION BOUNDARY
The same process contract lets infrastructure adapters evolve outside the runtime. In this deployment example, MHL keeps orchestration behind stable interfaces while pinned store extensions own the AWS integration.
Install an extension under the project’s .mhl/extensions/<id> directory. A user-wide copy may live under ~/.mhl/extensions/<id>, but the project-local copy wins.
my-project/
├── main.mh
└── .mhl/
├── extensions.lock # explicit allow-list and hashes
└── extensions/
└── com.acme.crm/
├── extension.mh # manifest and declarations
└── bin/crm # executable
An extension found on disk but absent from extensions.lock does not load. There is no automatic download; vendor the executable and pin it deliberately.
Define the extension in extension.mh, using MHL syntax. This file contains the manifest, properties, method signatures, and documentation. The manifest is read by mhl lint, the LSP, and extension diagnostics without spawning the process.
MINIMAL extension.mh
extensible crm {
manifest: {
id: "com.acme.crm",
api_version: "1",
executable: "bin/crm"
}
}
The extensible <kind> declaration identifies the capability. Its manifest block requires id, api_version, and executable. Add version for a versioned package and permissions to describe its access requirements.
WITH TOOLING METADATA AND PERMISSIONS
A properties: { name: Type } block describes configuration, and bare name(params): ReturnType signatures describe methods — the return type follows :, exactly as on a tool method. The older name(params) -> ReturnType form still loads, but mhl lint rejects it. A leading /** ... **/ block documents the kind; a trailing /// ... documents a property or method through the end of the line. These documentation forms are recognized within the extensible declaration.
/**
Fetch a customer by id from the Acme CRM.
**/
extensible crm {
manifest: {
id: "com.acme.crm",
version: "1.2.0",
api_version: "1",
executable: "bin/crm",
permissions: {
network: ["api.acme.com"],
secrets: ["env(\"CRM_TOKEN\")"]
}
}
properties: {
endpoint: string /// The Acme API base URL.
}
lookup(id: string): object /// Fetch a customer by id.
}
The kind connects an MHL declaration to your adapter. Properties and method signatures provide editor and lint metadata.
extension crm Customer {
endpoint: env("CRM_URL")
}
Several installed adapters may serve the same kind. A declaration then names one by its manifest id with from; without it, a shared kind fails as ambiguous. A kind built into MHL (mcp, a2a) cannot be served by an external adapter.
extension crm Customer from "acme.crm.salesforce" {
endpoint: env("CRM_URL")
}
Ship extension.mh at the extension package root, including in release archives. Keep all declarations in this file. mhl lint and the LSP validate the extensible declaration's shape: exactly one manifest block and no duplicate property or method names.
A trailing /// ... doc-comment runs to end of line, same as any comment — so a properties: { ... } block's closing } must be on its own line (or after a property with no trailing doc-comment). Written on the same line as the last documented property, the } is swallowed into the comment and parsing fails later, at the next declaration, with an unrelated-looking error.
Use one JSON object per line on stdout and stdin. There are no framing headers or batches. Lines larger than 8 MiB are rejected. Diagnostics belong on stderr, never in the JSON-RPC stream.
MESSAGE SHAPES
initializeNegotiate API version and identify the extension. Sent once, first.callInvoke one operation with the evaluated declaration, positional args, and named args.shutdownNotification at the end of the run. Flush and exit 0.logSend a diagnostic notification; the host redacts and forwards it.secret.resolveRequest one allow-listed secret reference from the host.{ "id": 1, "method": "initialize",
"params": { "api_version": "1", "host": "mhl", "host_version": "1.4.0-rc.2" } }
{ "id": 1, "result": {
"api_version": "1",
"extension": { "id": "com.acme.crm", "version": "1.2.0" },
"declarations": []
} }{ "id": 2, "method": "call", "params": {
"declaration": { "kind": "crm", "name": "Customer", "props": [] },
"operation": "lookup",
"args": ["123"], "named_args": {}
} }
{ "id": 2, "result": { "id": "123" } }
{ "id": 2, "error": { "code": "not_found", "message": "..." } }After sending { "method": "shutdown" }, the host closes stdin and waits about three seconds before forcing termination. Flush output and exit cleanly.
The extension process inherits no ambient environment beyond the host-provided extension variables and the manifest’s explicit env entries. Credentials are obtained only through secret.resolve and only when the manifest’s allow-list permits the exact reference.
{ "id": 7, "method": "secret.resolve",
"params": { "reference": "env(\"CRM_TOKEN\")" } }
{ "id": 7, "result": "secret-value" }
{ "id": 7, "error": {
"code": "denied", "message": "..."
} }{ "method": "log",
"params": { "message": "fetched 12 customers" } }The host captures stderr tails and errors after redaction. Never write protocol messages to stderr.
Of the manifest’s permissions, only secrets is enforced by the host today: secret.resolve is denied for any reference not on the allow-list. network, filesystem and subprocess are declarations, not a sandbox — the extension runs as an ordinary child process with the host’s own privileges and can reach any host, path or subprocess the OS lets that user reach. They exist for review, for an external policy layer, and for a future OS-level sandbox; the host prints a one-time notice when a manifest declares them. Run an untrusted extension inside a container or as an unprivileged user, and apply network/filesystem policy there. CLI agents are a separate execution path and do not carry even these declarations.
The CLI scaffolds the shape, exercises the protocol, captures declarations, and installs a pinned copy into a project — from a local directory, a git remote, or a published archive.
mhl extension init ext/scaffold the extension projectmhl extension test ext/handshake and probe declared methodsmhl extension package ext/capture handshake declarationsmhl extension install ext/copy, hash, and pin the adaptermhl extension install <url>[//subdir][#ref]clone a git remote, then copy, hash, and pinmhl extension install <url>.tar.gz[#sha256=hex]download an archive, verify, copy, hash, and pinmhl extension listshow locked entries and resolutionmhl extension doctorvalidate manifest, executable, API, and hashINSTALL FROM A REMOTE
# git — clone at a ref, optionally a subdirectory of a monorepo
mhl extension install https://github.com/acme/mhl-crm.git#v1.2.0
mhl extension install git@github.com:acme/monorepo.git//ext/crm#main
# archive — a .tar.gz / .zip published to a release (what `make release` emits)
mhl extension install https://github.com/acme/mhl-crm/releases/download/v1.2.0/mhl-crm.tar.gz
mhl extension install https://.../mhl-crm.tar.gz#sha256=<hex from SHA256SUMS>
A single string. For git: //subdir selects a directory inside the repo, #ref is a branch, tag, or commit; needs git on PATH. For an archive: the URL ends .tar.gz / .tgz / .zip, an optional #sha256=<hex> is checked against the download before anything is extracted, and the manifest is taken from the archive root or its single top-level directory. Both then follow the same copy → hash → pin path as a local install — the executable is never run.
LOCK ENTRY
{
"extensions": {
"com.acme.crm": {
"version": "1.2.0",
"sha256": "<hex sha256 of bin/crm>",
"source": "git@github.com:acme/monorepo.git//ext/crm#main",
"commit": "4a7d1ed414474e4033ac29ccb8653d9b1278fa8c"
}
}
}
source is set for any remote install (the git spec, or the archive URL including its #sha256=); commit only for git. A local-directory install writes just version and sha256, and an older lock without the extra fields still loads.
MULTIPLE PLATFORMS
One package can carry every platform's binary. Alongside the plain executable, drop a file per platform named <executable>-<goos>-<goarch> (…-windows-amd64.exe on Windows) in the same directory — the extension.mh is unchanged, the plain name is just the stem. mhl extension install resolves the running host (the plain file if present, else the -<goos>-<goarch> one), vendors only that binary, and the lock pins its sha256; a host with no matching binary fails with the list of platforms the package does provide. A conventional package layout keeps a metadata-only tree (manifest + README, for lint / review) separate from a release build that adds the per-platform bin/ and a .tar.gz plus SHA256SUMS — all pure-Go, CGO_ENABLED=0.
The host re-hashes the executable and refuses to run it if the digest differs from the lock. Treat the lock as the project’s explicit trust decision.
An extension is ready to distribute when another developer can install it, inspect its permissions, and diagnose a failure without reading its implementation.
Return the same api_version from initialize that the host requested.
Keep stdout to newline-delimited JSON-RPC and send human diagnostics to stderr.
Declare every supported kind, property, method, and permission in extension.mh.
Handle out-of-order responses and preserve request ids in every reply.
Test initialization, successful calls, structured errors, secret denial, and graceful shutdown.
Build a reproducible executable, run mhl extension doctor, and commit the lock entry.
Build a binary for every OS/architecture your consumers run — the runtime will not cross-compile for them — and state the supported set in the extension's README.
Official extension sources, manifests, integration tests, and release instructions live in the separate mh-language/mhl-packages repository ↗.