A TypeScript core reaches the host with Cmd.request(name, ...), which routes through runtime.effects.HostCallBinding. HostCallBinding is a public type and bindHostCalls() is public runtime API, but no generated path ever installs an app-authored binding — every generated entry point either wires the service carriers or null, and null rejects all non-reserved host requests (src/runtime/effects.zig: if (!fake and self.host_calls == null ...) return self.rejectStartedHost(...)).
So for the SDK's default authoring path, apps whose effects go beyond the built-in command set — custom IPC to a native daemon/service, BLE/hardware, proprietary vendor SDKs — cannot route commands into native code at all. The src/services escape hatch doesn't apply: services are TypeScript and can never reach C FFI.
What each target does today
-
Desktop: the generated runner hardcodes the choice (src/app_runner/ts_core_main.zig):
.host_calls = if (comptime use_pool)
pool_transport.binding()
else if (comptime use_child)
child_transport.binding()
else
null, // no app-authored binding possible
Real apps doing native effects work around this by patching the generated runner in zig-cache after the build stages it (add an @import of a host module, wire its binding() in). Undocumented and fragile, but possible.
-
Mobile: the staged mobile wiring (src/app_runner/ts_core_mobile.zig) sets host_calls from the in-process service pool only. There is no generated file an app can plausibly patch — it stages fresh from the SDK template on every build — so this is impossible, not just undocumented.
-
The C ABI shows the intended pattern but stops short: native_sdk_app_set_audio_service / _set_credential_service / _set_image_service are native-side registration seams, but there is no host-call equivalent. bindHostCalls() is only reachable by an embedder that owns the runtime loop — and the iOS tier deliberately owns it (src/tooling/ios.zig: "the toolkit owns the entire iOS app"), locking embedders out of the one API that would solve this.
-
A Zig core avoids the problem by construction (its logic is native code and calls FFI directly), which makes the gap specific to TS cores — the default path.
Notes / proposal
A small symmetric seam in both generated runners. We run this today (against 0.10.1) on mobile and are happy to turn it into a PR:
AppOptions.mobile_host (or a manifest declaration — may be cleaner): a module exposing binding() with the same contract as the desktop runner's host_calls value, plus its include dirs / object files.
- The build graph imports it into the staged mobile wiring as
app_host; the wiring references it under a comptime flag so apps without it never touch the import. Implementation note from doing this: the generated options module must be created once and shared between the exports and app modules — a file may belong to only one zig module.
- Desktop mirrors it in the generated runner's
else null fallback.
Happy to reshape to whatever fits (manifest-declared host module seems most in keeping with app.json/app.zon being the single source of app truth).
A TypeScript core reaches the host with
Cmd.request(name, ...), which routes throughruntime.effects.HostCallBinding.HostCallBindingis a public type andbindHostCalls()is public runtime API, but no generated path ever installs an app-authored binding — every generated entry point either wires the service carriers ornull, andnullrejects all non-reserved host requests (src/runtime/effects.zig:if (!fake and self.host_calls == null ...) return self.rejectStartedHost(...)).So for the SDK's default authoring path, apps whose effects go beyond the built-in command set — custom IPC to a native daemon/service, BLE/hardware, proprietary vendor SDKs — cannot route commands into native code at all. The
src/servicesescape hatch doesn't apply: services are TypeScript and can never reach C FFI.What each target does today
Desktop: the generated runner hardcodes the choice (
src/app_runner/ts_core_main.zig):Real apps doing native effects work around this by patching the generated runner in zig-cache after the build stages it (add an
@importof a host module, wire itsbinding()in). Undocumented and fragile, but possible.Mobile: the staged mobile wiring (
src/app_runner/ts_core_mobile.zig) setshost_callsfrom the in-process service pool only. There is no generated file an app can plausibly patch — it stages fresh from the SDK template on every build — so this is impossible, not just undocumented.The C ABI shows the intended pattern but stops short:
native_sdk_app_set_audio_service/_set_credential_service/_set_image_serviceare native-side registration seams, but there is no host-call equivalent.bindHostCalls()is only reachable by an embedder that owns the runtime loop — and the iOS tier deliberately owns it (src/tooling/ios.zig: "the toolkit owns the entire iOS app"), locking embedders out of the one API that would solve this.A Zig core avoids the problem by construction (its logic is native code and calls FFI directly), which makes the gap specific to TS cores — the default path.
Notes / proposal
A small symmetric seam in both generated runners. We run this today (against 0.10.1) on mobile and are happy to turn it into a PR:
AppOptions.mobile_host(or a manifest declaration — may be cleaner): a module exposingbinding()with the same contract as the desktop runner'shost_callsvalue, plus its include dirs / object files.app_host; the wiring references it under a comptime flag so apps without it never touch the import. Implementation note from doing this: the generated options module must be created once and shared between the exports and app modules — a file may belong to only one zig module.else nullfallback.Happy to reshape to whatever fits (manifest-declared host module seems most in keeping with app.json/app.zon being the single source of app truth).