The two halves
Device WASM and companion WASM are separate binaries with separate import sets. You compile twice; there is no universal build.
An app that spans your Mac and the device ships two WebAssembly binaries, not one:
device.wasm— runs on the device, in a small interpreter (WAMR) built for a microcontroller.companion.wasm— optional, runs in the companion on your Mac in a full engine (wasmtime).
They are built separately, signed separately (device.sig, companion.sig), and loaded by different hosts. There is no universal build — you compile the same source twice, each time against a different set of host imports.
What the two halves share
Both halves are driven through the same guest exports. The host calls into your module by name:
run— render the current screen.on_intent— a button press or nav verb arrived.on_value— a control changed.on_lifecycle— appear, disappear, connect, disconnect, pause, resume, stop.on_message— the other half sent you something.
And both link a common floor of host imports: drawing a screen, navigation, logging, and the message channel below. Write your render loop once against the floor and it behaves the same on either side.
Where the ABIs diverge
Each half then links imports the other cannot. This is the split that forces two builds.
Two import sets, one floor
The device half links gated device natives — the device's own clock (timer_now_ms), holding the screen awake (keep_awake_set), and reading its small on-device config and state (config_get, kv_get). It owns the pixels and the buttons.
The companion half links host_get and host_call — the generic bridge to your Mac. Through it the companion half reaches a network fetch, a notification, the clipboard, a URL, and a folder of its own. It is the only half that can touch your Mac; the device half has no such import.
Every one of these is capability-gated: the host checks the app's granted bitset and traps the call if the bit is missing. A half can only reach what its manifest asked for.
Don't call across the divide
Reaching for host_call from device.wasm, or keep_awake_set from companion.wasm, fails to link in that host. The import isn't there. Keep the Mac-side work in the companion half and the screen-and-clock work in the device half.
How the two halves talk
The halves never share memory. They pass discrete messages over a symmetric channel keyed to the running app:
app_send(ptr, len)queues an outbound message to the other half.app_recv(out, max)drains the next inbound message into a buffer you provide, returning its length or-1when the queue is empty.
Both imports live in the floor, so the code reads identically on each side. When a message lands, the host runs your on_message export; you drain the queue with app_recv and re-render. Keep messages small — one half sends, the other receives, and that is the whole contract between them.
The pull model
The host never writes into memory the guest didn't hand it. app_recv, config_get, host_get all take a guest-owned (ptr, max) and copy into it, returning the true length so you can tell when your buffer was too small. This is why the same import shapes work under both WAMR and wasmtime — neither host reaches into unowned guest pages.