Skip to main content

May 2026 (v0.13.0)

v0.13.0 completes the Mochi-to-BEAM/Erlang transpiler. Every phase of MEP-46 is now landed and the MEP is marked Final. You can compile a Mochi source file to a self-contained escript that runs on any OTP 27+ node, to a standalone rebar3 or Mix project, or to an OTP release tarball, without any native build step.

mochi build --target=beam-escript hello.mochi -o hello
./hello

The pipeline is entirely in-process: parser.Parse -> types.Check -> clower.Lower -> beamlower.Lower -> emit.Emit -> escript archive. No rebar3, no mix, no Makefile required for the default escript target.

1. BEAM transpiler: MEP-46 Final

MEP-46 covers 19 phases from hello-world through concurrency, LLM generation, HTTP fetch, collection types, closures, partial application, agents, streams, async/await, Erlang FFI, dependency management, and release packaging. All 19 phases are now LANDED.

1.1 Pipeline and escript packaging (Phases 0-1)

The transpiler pipeline lives under transpiler3/beam/. The entry point is mochi build --target=beam-escript, which parses Mochi source, runs the type checker, lowers to the aotir IR (reused from MEP-45), lowers again to Core Erlang via the cerl API, drives compile:forms/2 with from_core, and packs all resulting .beam files plus runtime modules into a zip-archive escript.

The compiler driver is transpiler3/beam/build/Driver.Build. The escript header sets -escript main mochi_main so the shell executes it directly. Runtime modules compiled alongside user code include mochi_str, mochi_fetch, mochi_json, mochi_llm, mochi_async, mochi_agent_server, mochi_agent_sup, mochi_stream, mochi_chan, mochi_file, mochi_list, and mochi_atoms.

BLAKE3 content-addressed cache. Unchanged source files are served from .mochi/cache/beam/ with a copy-only no-op. The cache key is BLAKE3 over file contents concatenated with a compilerVersion sentinel that rolls whenever code-gen semantics change.

CLI flags. --target=beam-escript, --out PATH, and --emit=core|erl|beam are available. --emit=core writes Core Erlang text for inspection.

1.2 Scalars, control flow, records, lists, maps (Phases 2-4)

Integers lower to BEAM integers (arbitrary precision). Floats lower to BEAM floats with IEEE 754 NaN/Inf propagation. Booleans lower to the atoms true and false. Strings lower to UTF-8 binaries.

Control flow: if/else lowers to c_case over the boolean; while lowers to a tail-recursive local function; for over a range lowers to lists:seq iteration; for over a collection lowers to a lists:foreach loop or a c_case on the list spine.

Records lower to BEAM maps keyed by atom field names. Field access is maps:get(field, Map). Field update is maps:put(field, Value, Map).

Lists lower to BEAM proper lists. append(xs, x) is xs ++ [x]. len(xs) is length(xs). Indexing is lists:nth(i+1, xs) with a one-based adjustment. map, filter, and reduce lower to lists:map/2, lists:filter/2, and lists:foldl/3.

Maps lower to BEAM maps. m[k] lowers to maps:get(k, M). m[k] = v lowers to maps:put(k, V, M). has(m, k) lowers to maps:is_key/2.

1.3 Collection types: set[T] and omap[K,V] (Phases 3.3-3.4)

set[T] uses OTP sets v2 (sets:from_list/1, sets:add_element/2, sets:is_element/2, sets:size/1, sets:to_list/1). Set literals {1, 2, 3} lower to sets:from_list([1,2,3]). The add(s, v) builtin calls sets:add_element/2. The has(s, v) builtin calls sets:is_element/2. SetType is a new type constructor in types/kinds.go.

omap[K,V] uses OTP orddict (orddict:from_list/1, orddict:fetch/2, orddict:store/3, orddict:is_key/2). Ordered map literals omap{"a": 1, "b": 2} lower to orddict:from_list([{"a",1},{"b",2}]). OMapType is a new type constructor. SetLiteral and OMapLiteral AST nodes were added to the parser; when, async, await, and spawn are new keywords.

1.4 Sum types, pattern matching, and guards (Phase 5)

Sum-type lowering. Variants lower to tagged tuples: Some(v) -> {some, V}, None -> none, Ok(v) -> {ok, V}, Err(e) -> {error, E}. Variant names are lowercased to follow Erlang atom conventions.

Match expression. match x { arm => expr, ... } lowers to c_case with one c_clause per arm. Nested patterns are supported. Exhaustiveness is checked by the type checker.

Guard clauses. The when keyword adds a guard to a match arm:

match x {
Some(n) when n > 0 => "positive",
Some(_) => "non-positive",
None => "empty",
}

Guards lower to the c_clause guard position in Core Erlang and compile to Erlang guard expressions, evaluated without side effects.

option[T] and Result[T,E]. These built-in sum types are fully supported in the type checker and lowered through the tagged-tuple scheme above. Pattern matching on Some/None and Ok/Err works in all match positions.

1.5 Closures and partial application (Phase 6)

Closures. Anonymous functions lower to c_fun. Free variables are captured by reference (BEAM closures are heap-allocated env records). Named functions can be passed as first-class values.

Partial application. A call with _ in argument positions creates a capturing closure:

let add = fun(a: int, b: int) -> int { a + b }
let add5 = add(5, _) // closure capturing 5 in position 0
print(add5(3)) // 8

hasUnderscoreArgs detects _ placeholders in the call argument list. lowerPartialApply synthesises a c_fun that captures the provided arguments and fills the _ slots with the closure's parameters.

1.6 Query DSL and Datalog (Phases 7-8)

Query DSL. from x in xs where pred select proj lowers to a BEAM list comprehension [Proj || X <- Xs, Pred]. Aggregations lower to lists:foldl. Group-by uses maps:from_list/1. Hash join pairs two comprehensions over a shared key. sort_by, take, and skip use lists:sort/2, lists:sublist/2, and a list-drop helper.

Datalog. Facts, rules, and recursive queries compile through mochi_datalog.erl using a semi-naive fixpoint evaluator with a per-relation delta table. Negation-as-failure is supported when the negation is stratifiable. Multi-free-variable queries are supported.

1.7 Agents and gen_server (Phase 9)

Agent declarations lower to a dispatch-based loop in mochi_agent_server.erl. Each agent type gets a start/2 entry that spawns a supervised server process.

spawn AgentType(args...) creates a running agent process. The SpawnExpr AST node lowers to mochi_agent_server:start/2. The returned PID is annotated with IsSpawnedRef = true in the IR so downstream passes treat it as an OTP process handle.

on close { ... } blocks. The OnCloseDecl AST node carries a body that runs when the agent terminates. This lowers to the terminate/2 callback in mochi_agent_server.erl.

Supervisor. mochi_agent_sup.erl is a simple_one_for_one dynamic supervisor with transient restart policy. Transient restart means a normal exit is not restarted, but an abnormal exit is.

1.8 Streams and backpressure (Phase 10)

stream<T> uses OTP pg process groups for MPMC broadcast. mochi_stream.erl implements publish/3, subscribe/2, and unsubscribe/2. Every subscriber receives every message published after it joined.

subscribe_limit(stream, N) adds backpressure. mochi_stream:subscribe_limit/2 implements a bounded subscriber loop: messages are dropped when the subscriber's buffer holds N items. should_drop/2 checks buffer depth. The type checker registers subscribe_limit(stream<T>, int): sub<T> as a builtin. SubMakeLimitExpr is the IR node.

Channels. mochi_chan.erl provides buffered SPMC channels with a ring-buffer backing store. chan<T> literals lower to mochi_chan:new/1.

1.9 async/await (Phase 11)

async { ... } lowers to mochi_async:async/1, which calls erlang:spawn_monitor and returns a future handle {future, Pid, Ref}.

await expr lowers to mochi_async:await/1, which performs a selective receive over the monitor reference and returns the value. If the monitored process fails, await propagates the error.

await_all(futures) waits for all futures in a list and returns a list of results in order, lowered to mochi_async:await_all/1. FutureType{Elem} is a new type constructor in types/kinds.go.

let f1 = async { fetch "https://example.com/a" into body; body }
let f2 = async { fetch "https://example.com/b" into body; body }
let results = await_all([f1, f2])

1.10 File I/O and Erlang FFI (Phase 12)

File I/O. readFile(path) and writeFile(path, content) lower to mochi_file:read/1 and mochi_file:write/2.

Extern Erlang. extern fun module.function(args): T declarations route calls directly to Erlang stdlib via module:function/arity using Core Erlang CCall. The OrigName field on ExternFuncDecl holds the dotted name; externErl in the BEAM lowerer maps it to a split (module, function) pair at emit time.

extern fun lists.sort(xs: list<int>): list<int>
let sorted = lists.sort([3, 1, 2])

mochi.toml -> rebar.config. When a mochi.toml is present next to the source file, the build driver parses the [dependencies] section (inline TOML parser, no external library) and emits a rebar.config next to the built artifact:

# mochi.toml
[dependencies]
cowboy = "2.10.0"
jsx = "3.1.0"

Emits:

{erl_opts, [debug_info]}.
{deps, [
{cowboy, "2.10.0"},
{jsx, "3.1.0"}
]}.

1.11 Builtins, panic, and try-catch (Phases 13.0-13.1)

String builtins: len, upper, lower, split, join, trim, contains, starts_with, ends_with, replace, str(x). Math builtins: abs, floor, ceil, round, sqrt, pow, log. List aggregates: sum, min, max, any, all.

All lower to the appropriate Erlang stdlib calls (string:uppercase/1, math:sqrt/1, lists:sum/1, etc.).

panic(code, msg) raises erlang:error({mochi_panic, Code, Msg}). Integer arithmetic errors are caught by wrapArithErr, which converts badarith to {mochi_panic, 5, "arithmetic error"}.

try/catch lowers to cerl.CTry catching {mochi_panic, ...} tuples. A unique tryNum counter ensures CTry variable names do not collide across nested try blocks.

1.12 LLM generation (Phase 13 LLM)

Cassette playback. generate provider { prompt: "..." } lowers to mochi_llm:generate/3. In cassette mode (MOCHI_LLM_CASSETTE_DIR set), responses are looked up from pre-recorded files keyed by a DJB2 hash of "provider\0model\0prompt". This makes LLM-using programs deterministic and credential-free in CI.

Structured output. generate provider { prompt: "...", schema: schema_var } appends "\nRespond with JSON matching this schema: <schema>" to the prompt before cassette lookup. The augmented prompt generates a different DJB2 key, so structured and unstructured cassettes do not collide.

Live providers. When MOCHI_LLM_CASSETTE_DIR is not set, mochi_llm.erl dispatches to a real provider:

  • OPENAI_API_KEY set: POST to api.openai.com/v1/chat/completions using OTP httpc with TLS verify-peer and public_key:cacerts_get/0 for the system CA bundle. Default model gpt-4o-mini. Parses choices[0].message.content via OTP 27 json:decode/1.
  • ANTHROPIC_API_KEY set: POST to api.anthropic.com/v1/messages with anthropic-version: 2023-06-01. Default model claude-haiku-4-5-20251001. Parses content[0].text.
  • Neither set: warning to stderr, returns empty string.

Dispatch order: cassette > OpenAI > Anthropic.

1.13 HTTP fetch (Phase 14)

fetch URL into var lowers to mochi_fetch:get/1. The runtime module uses OTP httpc (from inets) with:

  • TLS 1.3 preferred, TLS 1.2 fallback.
  • Certificate verification via {verify, verify_peer} and {cacerts, public_key:cacerts_get/0} for the system CA bundle. No {verify, verify_none} shortcuts.
  • inets and ssl applications started on first call via application:ensure_all_started/1.

The URL can be a string literal or any expression that evaluates to a string. The body is returned as a UTF-8 binary. Non-200 responses return an empty binary and log to stderr.

json_decode(s) lowers to mochi_json:decode/1. The runtime module wraps OTP 27 json:decode/1 and coerces all values to binaries: null -> <<"null">>, booleans -> <<"true">>/<<"false">>, integers via integer_to_binary/1, floats via float_to_binary/2 with compact format, nested objects/arrays re-encoded via json:encode/1. Returns a map<string,string>.

1.14 Release packaging (Phase 15)

Five output targets are now available:

TargetFlagOutput
TargetEscript--target=beam-escriptSingle-file escript (default)
TargetRebar3Project--target=beam-rebar3-projectrebar3 project directory
TargetMixProject--target=beam-mix-projectMix project directory
TargetRelease--target=beam-releaseOTP release via rebar3 release
TargetAtomVM--target=beam-atomvm.avm bundle via packbeam

TargetRebar3Project emits:

  • rebar.config with {erl_opts,[debug_info]} and deps from mochi.toml.
  • src/mochi_app.app.src with the OTP application resource.
  • src/*.erl with all runtime Erlang sources copied in.
  • ebin/*.beam with all pre-compiled user and runtime modules.

The output is a valid rebar3 project. Running rebar3 compile in it recompiles from source; running rebar3 dialyzer runs Dialyzer.

TargetMixProject emits mix.exs (with deps from mochi.toml) and ebin/*.beam. The output is a valid Mix project.

TargetRelease builds the rebar3 project, appends a {relx, ...} stanza, and runs rebar3 release to produce a self-contained OTP release under _build/default/rel/mochi_app/ with bundled ERTS. The test auto-skips if rebar3 is not on PATH.

TargetAtomVM packs all compiled .beam files into a .avm bundle by calling packbeam create. The test auto-skips if packbeam is not on PATH. Only Mochi programs that stay within Phases 1-5 (no pg, httpc, or crypto) run on AtomVM.

Dockerfile.beam-otp27 is a multi-stage Docker recipe. The build stage uses golang:1.24 to compile the mochi binary; the runtime stage is erlang:27-slim with CA certificates installed for mochi_fetch TLS support.

docker build -f Dockerfile.beam-otp27 -t mochi:beam-otp27 .
docker run --rm mochi:beam-otp27 build --target=beam-escript hello.mochi

1.15 Multi-OTP matrix (Phase 16)

The CI matrix (transpiler3-beam-test.yml) runs the full BEAM fixture corpus on:

  • OTP 27.latest + Linux x86-64 (blocking)
  • OTP 27.latest + macOS arm64 (blocking)
  • OTP 28.latest + Linux x86-64 (blocking)
  • OTP 28.latest + macOS arm64 (blocking)
  • OTP 29 RC + Linux x86-64 (non-blocking nightly)
  • Windows x86-64 best-effort (non-blocking)

Every fixture checks byte-equal stdout against the vm3 oracle. The matrix uses erlef/setup-beam to install OTP versions without system packages.

1.16 Dialyzer cleanliness (Phase 17)

-spec attributes are emitted for every exported Mochi function, with argument types in Erlang type syntax (integer(), float(), binary(), [T], #{K => V}, {ok, T}, {error, E}).

-opaque attributes are emitted for agent and stream reference types so Dialyzer can enforce that internal representations are not pattern-matched outside the owning module.

The CI job transpiler3-beam-dialyzer.yml runs dialyzer on all runtime .erl sources and reports zero warnings. A dialyzer_allowlist.txt documents the small set of known-benign patterns that require suppression.

1.17 Reproducibility (Phase 18)

Deterministic .beam files. compile:forms/2 is called with the deterministic flag, which strips the CInf timestamp chunk. The BEAM lowerer sorts mod.Defs by (name, arity) before emission to canonicalise definition order.

Reproducibility test. TestReproducibility compiles the same fixture twice in separate temp directories, opens the escript ZIP, and compares the SHA-256 of every .beam entry byte-for-byte. Both builds are identical.

CI gate. transpiler3-beam-repro.yml runs TestReproducibility on every PR against main.

Benchmark harness. BenchmarkBeamBuild, BenchmarkBeamQuery, and BenchmarkBeamRun are available in transpiler3/beam/build/bench_test.go.

go test -bench=. ./transpiler3/beam/build/

1.18 Hex.pm packaging (Phase 19.2)

The mochi_runtime Hex.pm package is ready for publication. The runtime rebar.config at transpiler3/beam/runtime/rebar.config carries full Hex metadata: package name mochi_runtime, license Apache-2.0, GitHub link, description, and a file list covering src/, include/, LICENSE, README.md, and rebar.config. Publish with rebar3 hex publish inside transpiler3/beam/runtime/.

2. Test corpus

The BEAM transpiler test suite lives in transpiler3/beam/build/ and covers all 19 phases with 29 test functions:

Test functionPhaseFixtures
TestPhase1Hello13
TestPhase2Primitives230
TestPhase3Collections325
TestPhase03_3Set3.33
TestPhase03_4OMap3.43
TestPhase4Records425
TestPhase05_1Guards5.13
TestPhase05_2Option5.23
TestPhase05_3Result5.33
TestPhase06_2PartialApply6.23
TestPhase09_1Spawn9.13
TestPhase09_3OnClose9.32
TestPhase09_4Supervisor9.42
TestPhase10_2SubscribeLimit10.22
TestPhase10_3CrossNodeStreams10.31
TestPhase11Async115
TestPhase12_1ExternErlang12.13
TestPhase12_2MochiToml12.21
TestPhase12_2NoToml12.21
TestPhase13LLM13 LLM5
TestPhase13LLMStructured13.11
TestPhase13_2LiveProviderRouting13.21
TestPhase14Fetch143
TestPhase14_2JSONParse14.21
TestPhase15_1Rebar3Project15.12
TestPhase15_2MixProject15.22
TestPhase15_0Release15.01 (skip if no rebar3)
TestPhase15_3AtomVM15.31 (skip if no packbeam)
TestReproducibility181

All tests are green on OTP 27 and OTP 28 on Linux x86-64 and macOS arm64.

3. New parser syntax

v0.13.0 adds eight new parser keywords and twelve new AST nodes.

Keywords: set, omap, when, async, await, spawn, close, extern

AST nodes added to Primary:

  • SetLiteral{Elems []*Expr} -- 'set' '{' elems '}'
  • OMapLiteral{Items []*MapEntry} -- 'omap' '{' entries '}'
  • AsyncExpr{Body *Expr} -- 'async' expr
  • AwaitExpr{Future *Expr} -- 'await' expr
  • SpawnExpr{Agent string; Args []*Expr} -- 'spawn' Ident '(' args ')'

AST nodes added to MatchCase:

  • Guard *Expr -- 'when' expr between pattern and =>

AST nodes added to AgentBlock:

  • OnCloseDecl{Body []*Statement} -- 'on' 'close' '{' stmts '}'

AST nodes added to ExternDecl:

  • ExternFuncDecl.OrigName string -- dotted Erlang name module.function

Parser invariants in parser/invariants.go were updated for all new Primary fields.

4. New type system entries

New type constructors in types/kinds.go:

  • SetType{Elem Type} -- "set[T]"
  • OMapType{Key, Value Type} -- "omap[K,V]"
  • FutureType{Elem Type} -- "future<T>"

New builtins in types/check.go:

  • add(set<T>, T): set<T>
  • has(set<T>, T): bool
  • has(omap<K,V>, K): bool
  • await_all(list<future<T>>): list<T>
  • subscribe_limit(stream<T>, int): sub<T>
  • json_decode(string): map<string,string>

Generic type resolution in types/resolve.go: new cases for "set", "omap", and "future" generic type names.

5. New IR nodes (aotir)

New type constants in transpiler3/c/aotir/types.go: TypeSet, TypeOMap, TypeFuture

New expression nodes in transpiler3/c/aotir/program.go:

NodePurpose
SetLiteralExprset literal
SetAddExpradd(set, elem)
SetHasExprhas(set, elem)
OMapLiteralExpromap literal
OMapGetExprm[k] on omap
OMapSetExprm[k] = v on omap
OMapHasExprhas(omap, k)
AsyncExprasync { ... }
AwaitExprawait future
AgentSpawnExprspawn AgentType(args)
JsonDecodeExprjson_decode(s)
SubMakeLimitExprsubscribe_limit(stream, N)

MatchArm gained a Guard Expr field. AgentDecl gained an OnClose *Block field. ExternFuncDecl gained OrigName string. VarRef gained IsSpawnedRef bool.

6. Compatibility

v0.13.0 is additive. All existing Mochi programs continue to run under vm3 with mochi run. The mochi build --target=c-aot path from v0.12.0 is unchanged. mochi build --target=beam-escript is the new entry point for BEAM output.

OTP 27 is the minimum supported OTP version, required for json:decode/1 (stdlib), public_key:cacerts_get/0 (system CA bundle), and the deterministic compile flag. OTP 28 and OTP 29 RC are supported and tested in CI.

7. Upgrade

curl -fsSL https://get.mochi-lang.dev | sh
mochi --version # 0.13.0

Or, with Docker:

docker pull ghcr.io/mochilang/mochi:0.13.0

Or, with the new BEAM-specific image:

docker pull ghcr.io/mochilang/mochi:beam-otp27

Or, from source:

git pull && make build

Pre-built binaries for all five tier-1 triples are available on the GitHub release page.