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_KEYset: POST toapi.openai.com/v1/chat/completionsusing OTPhttpcwith TLS verify-peer andpublic_key:cacerts_get/0for the system CA bundle. Default modelgpt-4o-mini. Parseschoices[0].message.contentvia OTP 27json:decode/1.ANTHROPIC_API_KEYset: POST toapi.anthropic.com/v1/messageswithanthropic-version: 2023-06-01. Default modelclaude-haiku-4-5-20251001. Parsescontent[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. inetsandsslapplications started on first call viaapplication: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:
| Target | Flag | Output |
|---|---|---|
TargetEscript | --target=beam-escript | Single-file escript (default) |
TargetRebar3Project | --target=beam-rebar3-project | rebar3 project directory |
TargetMixProject | --target=beam-mix-project | Mix project directory |
TargetRelease | --target=beam-release | OTP release via rebar3 release |
TargetAtomVM | --target=beam-atomvm | .avm bundle via packbeam |
TargetRebar3Project emits:
rebar.configwith{erl_opts,[debug_info]}and deps frommochi.toml.src/mochi_app.app.srcwith the OTP application resource.src/*.erlwith all runtime Erlang sources copied in.ebin/*.beamwith 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 function | Phase | Fixtures |
|---|---|---|
TestPhase1Hello | 1 | 3 |
TestPhase2Primitives | 2 | 30 |
TestPhase3Collections | 3 | 25 |
TestPhase03_3Set | 3.3 | 3 |
TestPhase03_4OMap | 3.4 | 3 |
TestPhase4Records | 4 | 25 |
TestPhase05_1Guards | 5.1 | 3 |
TestPhase05_2Option | 5.2 | 3 |
TestPhase05_3Result | 5.3 | 3 |
TestPhase06_2PartialApply | 6.2 | 3 |
TestPhase09_1Spawn | 9.1 | 3 |
TestPhase09_3OnClose | 9.3 | 2 |
TestPhase09_4Supervisor | 9.4 | 2 |
TestPhase10_2SubscribeLimit | 10.2 | 2 |
TestPhase10_3CrossNodeStreams | 10.3 | 1 |
TestPhase11Async | 11 | 5 |
TestPhase12_1ExternErlang | 12.1 | 3 |
TestPhase12_2MochiToml | 12.2 | 1 |
TestPhase12_2NoToml | 12.2 | 1 |
TestPhase13LLM | 13 LLM | 5 |
TestPhase13LLMStructured | 13.1 | 1 |
TestPhase13_2LiveProviderRouting | 13.2 | 1 |
TestPhase14Fetch | 14 | 3 |
TestPhase14_2JSONParse | 14.2 | 1 |
TestPhase15_1Rebar3Project | 15.1 | 2 |
TestPhase15_2MixProject | 15.2 | 2 |
TestPhase15_0Release | 15.0 | 1 (skip if no rebar3) |
TestPhase15_3AtomVM | 15.3 | 1 (skip if no packbeam) |
TestReproducibility | 18 | 1 |
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' exprAwaitExpr{Future *Expr}--'await' exprSpawnExpr{Agent string; Args []*Expr}--'spawn' Ident '(' args ')'
AST nodes added to MatchCase:
Guard *Expr--'when' exprbetween pattern and=>
AST nodes added to AgentBlock:
OnCloseDecl{Body []*Statement}--'on' 'close' '{' stmts '}'
AST nodes added to ExternDecl:
ExternFuncDecl.OrigName string-- dotted Erlang namemodule.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): boolhas(omap<K,V>, K): boolawait_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:
| Node | Purpose |
|---|---|
SetLiteralExpr | set literal |
SetAddExpr | add(set, elem) |
SetHasExpr | has(set, elem) |
OMapLiteralExpr | omap literal |
OMapGetExpr | m[k] on omap |
OMapSetExpr | m[k] = v on omap |
OMapHasExpr | has(omap, k) |
AsyncExpr | async { ... } |
AwaitExpr | await future |
AgentSpawnExpr | spawn AgentType(args) |
JsonDecodeExpr | json_decode(s) |
SubMakeLimitExpr | subscribe_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.