The manifest
What the device sees, what stays on the host, and what is signed.
Your manifest is a manifest.json — one file inside the .zeph-app ZIP, alongside device.wasm and its signature. It carries everything about your app: id, name, icon, the screens it registers, the capabilities it wants, its settings form, its assets. It is also the source the install consent prompt renders from.
The load-bearing split: most of it never reaches the device. Only a small, fixed subset is encoded into the package doc the device stores. Everything else stays on your Mac — and the fields that actually grant power ride the signed metadata, not the document either side keeps.
What reaches the device
At install, the companion encodes a compact package doc and registers it on the device. That doc holds only what the device needs to draw and route your app:
- id, name, icon — how it appears in the launcher.
- faces, widgets, pages — the screens you register, as pre-rendered layout.
- intents — the actions a button or the assistant can fire at it.
- room — a full-screen surface, if the app declares one.
- dependence — whether it is Standalone, Enhanced, or Required, so the offline overlay reads the right value.
That is the whole list. Names and ids are length-checked and ids must be dot-free — the device composes references as app_id.export_id, so a dot would misroute the split.
What stays on your Mac
The rest of the manifest is companion-local. It shapes the Library, the store listing, the consent prompt, and the Configure tab — but it is never written to the device's flash:
- byline, description, icon_ref, categories — identity for Library and store surfaces.
- requires_features — a hard install gate (e.g.
audio.mic) checked against the board before install. - glances, skills — prebaked glances and bundled agent skills, installed beside the app.
- config_schema — the JSON Schema for the app's Configure tab; values persist on your Mac.
- x_allowed_hosts — the network allowlist the companion enforces.
- requires, companion_caps — the capability bitsets. These the consent prompt enumerates, but the device does not learn them from this doc.
Capabilities and version are signed, not stored
Capabilities are the point of the split. The device does not read requires from the package doc — it gets its grant from the signed transfer metadata, verified against the pinned app-signing key the moment the code loads.
What the signature binds
Because the digest binds all three, a bundle cannot be quietly re-labelled. Escalate requires in the manifest, bump the version string, flip a byte of the code — each one breaks the signature, and the install is refused before anything is written. A hosted app signs its companion.wasm the same way, over the companion caps. A present-but-unsigned companion part is refused outright.
Assets are bound too
Every image and audio asset the manifest lists carries a sha256. At verify time the companion re-hashes each bundled part and compares — a swapped or missing asset is rejected. (The signature itself binds the device.wasm bytes plus the capabilities and version; the asset hashes are what catch a tampered asset.)
Authoring one
You do not hand-write the whole thing from scratch. The CLI builds and signs a bundle in one step; a base --manifest JSON carries the rich fields (glances, config_schema, widgets, faces) that flags cannot express, and scalar flags override on top.
Build the device part
Compile your app to a wasm32 device.wasm. That is the code the signature will cover.
Author the manifest
Write a manifest.json with your id, name, icon, the screens you register, and any config_schema, glances, or assets. This is the file the consent prompt reads.
Sign it into a bundle
Run zeph app bundle with your app-signing key. The --caps you pass become the signed requires; --version becomes the bound semver. Add --companion for a hosted app's second half, and --audio (or an audio_sources map in the manifest) for on-device clips.
An app that wants the network must name its hosts
If your manifest requests a network capability, x_allowed_hosts must name at
least one host glob — there is no silent default-allow. An empty allowlist with
a network capability is rejected at install, because the companion proxies every
fetch and would otherwise let it reach nothing.