· 4 min read
Someone else owns main
Roc applications hand main! to a platform that owns startup, effects, allocation, and exit — a tenancy agreement written in checked syntax.

A Roc program opens with a small act of submission. The first line picks a platform, and that choice dictates everything after it: what the application must provide, what it may import, which effects exist at all. The main! in the echo example below is not an entrypoint — it is a value the application hands over, typed to someone else's specification.
app [main!] { pf: platform "./echo-platform/main.roc" }import pf.Echomain! = |_args| { Echo.line!("Hello from Roc") Ok({})}I think this is the most honest move in recent language design. Your program was always a guest — libc called your main, the JVM owned your threads, the browser owned your loop — and Roc simply writes the tenancy agreement in syntax the compiler checks. Even the Ok({}) at the bottom is dictated from outside, since the platform demands a Try and its adapter decides what your success turns into.
The two headers meet at a type#
Here is the landlord's side, from Roc's echo platform at commit a06fe3f. Six declarations, and none of them is build configuration — this is a division of power.
platform "" requires {} { main! : List(Str) => Try(_, [Exit(I8), ..]) } exposes [Echo] packages {} provides { "roc_main": main_for_host! } hosted { "roc_echo_line": Echo.line! }- 1The application's debt: a
main!that takes the argument list and returns aTrycarrying an exit effect. - 2The application's entire visible world — nothing outside this list can be imported.
- 3The adapted entrypoint, sent back across the boundary under the symbol
roc_main. - 4
Echo.line!resolves to a function the host implements in Zig.
Want to write a file from this application? You can't. No hosted symbol, no effect; the import does not exist to be written.
| Owner | Contract | What stays inside |
|---|---|---|
| Application | Choose one platform; provide main! | Domain logic and values permitted by that platform |
| Platform module | Require main!; expose Echo; adapt main! to roc_main | The typed contract between Roc code and its host |
| Zig host | Own the OS entrypoint; allocate the runtime; run Roc | Process arguments, diagnostics, hosted functions, and exit code |
The adapter under that header also settles what your program's result means as process behavior: success becomes exit code zero, an explicit Exit(code) passes through, and other failures get printed before one is returned. A framework calls your callback after a conventional main() and hopes you behave — Roc puts the same split behind a checked interface.
The host has the actual keys#
The other side of the contract is ordinary Zig, and the same commit lets you read it. The native entrypoint reads process arguments, sets up a 128 MiB fixed-buffer allocator, installs diagnostics, and calls into the shared runner, which registers Echo.line! and finally runs the compiled Roc code.
Promise
The application supplies main!
Its type is the requirement declared by the chosen platform.
Adapt
The platform exports roc_main
main_for_host! converts the application's Try result into an I8 exit code.
Start
The Zig process enters first
It reads arguments, installs diagnostics, and allocates the runtime arena.
Dispatch
The host invokes Roc
The runner registers Echo.line! and executes the compiled entrypoint.
You can imagine where this goes — a browser host with its own scheduling, a server host whose allocator lives and dies with a request — though the echo example proves none of that, and I would not promise it on one pinned commit. What the source does establish is narrower: the host owns the process and its resources, and the application receives exactly the capabilities its platform wrote down.
A checked boundary can still become a kingdom#
Now the uncomfortable part. Selecting a platform concentrates power in it: its API determines every available effect, its adapter defines process behavior, and its host joins your trusted computing base. Since two platforms need not even expose the same application type, replacing one is closer to changing frameworks than to swapping a library — the checking that makes the boundary safe also raises the cost of crossing to a different one.
Whether a mature platform ecosystem makes that price tolerable is a question the echo example cannot answer. It does show precisely where the bill accumulates, down to the last line of the native entrypoint: Zig takes the code the runner returned and calls std.process.exit(code). By then your function is long gone.