native-capabilities
Native capabilities — the device is a class, each feature is a catalog row
How a Zero scene reaches a real device capability — camera, location, haptics, share, dialer,
clipboard, biometrics, files — through one uniform, data-driven mechanism, not one bespoke
node per feature. Every hardware/OS feature is a capability = one row in a catalog. Any action
emits it by name through inherited helpers; the platform shell that draws the scene supplies the
method through a small set of generic dispatchers keyed by kind. So adding a feature is a
catalog row, not code (except the rare bespoke live view).
>
Status. The store side is built & verified here: the capability catalog (65 rows), the
inherited thing._native_* helpers, the os browser class (os.content / os.cap / os.result
/ os.unsupported) and the platform subclasses, and trust.scan migrated onto _native_view. The
shells' generic per-kind dispatchers are added on the existing bridge=/onNative seams (the one
intended code change per platform) and build in CI / need a device — see *Honesty* and
*Verification* below.
>
Companion to [native-rendering.md](native-rendering.md) (the engine emits scenes, the viewer
draws) and [ARCHITECTURE.md](ARCHITECTURE.md) / [VISION.md](VISION.md). Keep in sync when you
touch os/capabilities.jsonl, a thing/_native_*.action helper, a native/one-shot button node,
or a shell dispatcher (mobile/android/.../NativeBridge.kt, iOS SceneRenderer, desktop shells).
The one idea
Everything in Zero already follows one shape: a folder is a class, an .action is a method.
A device's native powers are no different — the device is a class, and each hardware/OS feature is a
capability. A capability is *one row* in the catalog os/capabilities.jsonl. The DSL never says
"use the Android camera"; an action names a capability (share.text, camera.qr, geo.once) and
whichever platform is drawing supplies the method.
The payoff is that adding a feature is a catalog row, not code. A row carries everything the
shell needs to execute the feature on every platform. The shell learns, *once per platform*, how to
run each kind of capability; from then on a new feature of that kind is pure data.
graph LR
ROW["os/capabilities.jsonl<br/>1 row = 1 capability<br/>(kind + per-platform descriptor)"] --> H["thing._native_do / _native_view<br/>(inherited by every class)"]
H --> W["scene wire<br/>button(bridge,cap,capkind,p1..3,and/ios/mac/win/lin)<br/>or native(cap,p1..5,and,ios) + fallback kid"]
W --> D["shell dispatcher, keyed by kind"]
D -->|intent| OS1["OS intent / URL / opener"]
D -->|effect| OS2["vibrate · clipboard · notify · toast · torch · tts"]
D -->|value| OS3["clipboard.read · geo · battery · info · network · biometric"]
D -->|view| OS4["registry: cap -> native view (camera.qr, web.view)"]
W -.->|web / no bridge / unknown cap| FB["fallback: row.fallback URL or the native node's kids"]
The catalog — the single source of truth
os/capabilities.jsonl is the one place every OS/hardware feature lives — one compact JSON object
per line:
{"id":"share.text","title":"Share text","kind":"intent","area":"share",
"desc":"Open the OS share sheet with some text.",
"params":["text"],"platforms":["android","ios","mac","linux","windows"],
"android":{"action":"android.intent.action.SEND","type":"text/plain",
"extras":{"android.intent.extra.TEXT":"{1}"}},
"ios":{"kind":"share","items":["{1}"]},
"fallback":"/os/unsupported?cap=share.text"}
Field
Meaning
id
the capability name, namespaced area.verb — the contract every caller uses
title / desc
human label + one-line description (drives the browser pages)
kind
view \
intent \
effect \
value — selects the shell dispatcher
area
grouping (camera, share, comms, location, system, clipboard, …)
params
parameter names, in order — they bind to placeholders {1} {2} {3}
platforms[]
which platforms back this row natively (membership = the platform name is in this array)
<platform> blocks
android / ios / mac / windows / linux — the descriptor the shell executes
fallback
where a non-backing shell (or the web) degrades to — a helpful page or a plain URL
Kinds.
• view — a live view drawn *in the layout* (camera preview/scan, embedded map/web view). Emits a
native scene node; the shell fills a host view.
• intent — hand off to the OS: fire an Android intent, open a tel:/mailto:/geo: URL, or
the desktop opener. Some (returns:true) come back with a value.
• effect — a fire-and-forget device effect: vibrate, write clipboard, notify, toast, torch, TTS,
app.update (the shell installs the newer build of itself). That last one is the argument for
this whole mechanism in one row: /node/updates is ONE screen that has to work on a laptop, where
the engine is a file the node fetches and re-execs into, and on a phone or a TV, where it is inside
the APK and only the shell can replace it. The DSL names the capability; each platform supplies the
method; a viewer with no bridge falls back to the row's /download. Nothing about the update itself
moves into the catalog — Updater is still the only downloader and the system installer the only
installer.
• value — read something and post it back: clipboard text, location, battery, device info,
network status, biometric result.
{1..3} param binding. params names map positionally onto {1}, {2}, {3} inside the
platform descriptors. A caller supplying p1="Hello" makes the shell substitute {1}→Hello
everywhere in that platform's descriptor before executing. Empty params substitute as empty strings.
An optional "perm" inside a platform block names the OS permission to request first.
The inherited thing._native_* helpers — mostly on actions
The catalog is reached through three helpers on the root thing class, so any class inherits them
and can reach any capability by name, with no code:
Helper
Emits
Use
[thing._native_spec](../thing/_native_spec.action)
*(nothing)* → returns the catalog row for an id
the lookup every emitter uses; matches by id via a foreach + _native_match that sets a global on the first hit
[thing._native_do](../thing/_native_do.action)
a one-shot button node
intent / effect / value capabilities
[thing._native_view](../thing/_native_view.action)
a live-view native node (opens *and* closes the container)
view capabilities
Because they live on thing, any action anywhere writes, e.g.:
_native_do "share.text" "Share text" "/os/result" "Hello from Zero" "" ""
_native_view "camera.qr" "/trust/scanresult" "Scan a code" "/trust/scan?raw=1" ""
_native_do $1=cap $2=label $3=result $4..$6=p1..p3 resolves the spec, reads its kind and the
per-platform descriptors, and emits the button. _native_view $1=cap $2=result $3=title $4=fallback
$5..$9=p1..p5 emits the native node with the fallback as its kid. Neither helper knows anything about a
specific feature — they just project a catalog row onto the wire.
The emitted scene wire — VERIFIED
One-shot capability → a button node (rides the existing bridge= seam)
_native_do emits (verified via os.cap?id=share.text):
{"type":"button","props":{
"label":"Share text","kind":"secondary",
"bridge":"share.text","cap":"share.text","capkind":"intent",
"result":"/os/result",
"p1":"Hello from Zero","p2":"","p3":"",
"and":"{\"action\":\"android.intent.action.SEND\",\"type\":\"text/plain\",\"extras\":{\"android.intent.extra.TEXT\":\"{1}\"}}",
"ios":"{\"kind\":\"share\",\"items\":[\"{1}\"]}",
"mac":"","win":"","lin":"",
"action":"/os/unsupported?cap=share.text"
}}
• and / ios / mac / win / lin are the per-platform descriptor as a JSON string (parse
it). Empty string = this platform doesn't back the row.
• The shell binds {1}→p1, {2}→p2, {3}→p3 inside its descriptor, then executes by
capkind. result is where a returning cap posts its output (result?value=<urlencoded>).
• action is the web / no-bridge fallback — the row's fallback (a helpful page or a plain URL),
so the button still does something useful without a native shell.
Live-view capability → a native node (rides the existing onNative seam)
_native_view emits (verified via trust.scan):
{"type":"native","props":{
"cap":"camera.qr","result":"/trust/scanresult","title":"Scan a zero sign-in code",
"p1":"","and":"{\"view\":\"camera.qr\",\"perm\":\"android.permission.CAMERA\"}","ios":""},
"kids":[{"type":"webview","props":{"src":"/trust/scan?raw=1"}}]}
• The shell's SceneRenderer.buildNative hands (cap, node, host) to its NativeBridge registry,
which fills the host with the live view.
• kids are the fallback. A viewer with no native bridge, or one whose registry lacks cap,
renders the kids instead (the renderer's *unknown-type → render kids* branch) — so a native node
degrades to a webview / paste form / plain message on web and older shells, and auto-upgrades to
the real capability the moment a shell ships it. Forward- and backward-compatible by construction.
The shell "touch once" — generic dispatchers keyed by kind
The shell wires each kind *once*; thereafter a new feature is a catalog row. The existing seams
stay:
• onNative(cap, node, host) -> Bool for view caps — the registry. Bespoke views (camera.qr)
key off cap; a generic view dispatcher reads and.view == "web.view" + and.url and hosts a
WebView. Unknown/absent → render the fallback kids.
• The button bridge= seam for one-shots. When props.capkind ∈ {intent, effect, value}, the
shell runs the matching generic dispatcher (binding {1..3} = p1..p3 from *this* platform's
descriptor); otherwise it falls through to the shell's existing verb handling (e.g. the auth verbs).
The three one-shot dispatchers:
1. intent — build & fire the OS intent / URL / opener from the descriptor. If returns:true,
capture the result and navigate result?value=…. Descriptor keys: action, data, type,
extras, launchPackage, returns (Android); {"kind":"share"|"url", …} (iOS);
{"open":…} / {"exec":[…]} (mac/linux/windows).
2. effect — a small keyed switch: vibrate · clipboard.write · notify · toast ·
flashlight · tts. Reusing an existing key for a new row is zero code; a genuinely new key
is one branch.
3. value — a small keyed switch: clipboard.read · geo.once · battery · device.info ·
network · biometric; read → navigate result?value=….
Permissions. Request the descriptor's "perm" via the platform's runtime-permission flow before
acting; on denial, show a message or navigate the fallback. The three-gate model (capability installed
• OS permission + zcap) from [ARCHITECTURE.md](ARCHITECTURE.md) still holds.
The os browser + platform classes — "the device is a class"
The catalog is *browsable*, and every page is built entirely from os/capabilities.jsonl — so it
grows with the catalog and never needs editing:
Class / action
What it does
[os / os.content](../os/content.action)
the master catalog: every capability grouped by area (camera, comms, control, …) via os/_cap_row — the catalog is area-sorted and a heading is emitted on each area change, so it stays navigable as it grows
[os.cap?id=<cap>](../os/cap.action)
one capability's detail + an interactive try — a view cap embeds _native_view; a one-shot renders an inputs form (per the row's params) that POSTs back, baking the real values into the live _native_do button so you invoke it for real
[os.result?value=…](../os/result.action)
the neutral sink where a returning cap (value, or returns:true intent) posts its output
[os.unsupported?cap=<id>](../os/unsupported.action)
the honest fallback page a device-only cap degrades to on the web / a shell that doesn't back it
[os.manifest](../os/manifest.action)
the machine-readable capability manifest (ndjson) — the *"this device declares what it can do"* bridge a shell/peer/tool fetches to discover every capability
Platform subclasses. Each platform is an os subclass — android, ios, mac, windows,
linux, tv — that answers *"if this is an Android, what can it reach?"*. The page is built from the
same catalog via [os._plat_row](../os/_plat_row.action), which marks each row native or
fallback purely by testing whether the platform's name is in that row's platforms[] array. No
per-platform capability list is hand-maintained: *"a platform is just another class, and each native
feature is one method it implements."* (android is live today; the rest follow the identical
pattern.)
Capability catalog (63 rows)
The catalog currently holds 63 capabilities — 7 view, 30 intent, 14 effect, 12 value. "Native
on" lists the platforms whose platforms[] includes that platform; every other platform (and the web)
uses the row's fallback. The table is generated from the rows — the catalog itself is authoritative.
view (7) — a live view in the layout (native node)
Capability
Area
Params
Native on
camera.qr
camera
result
android
camera.capture
camera
result, mode, facing, torch, seconds, guide
android
mic.capture
media
result, seconds, guide
android
web.view
web
url
*(none yet — generic view dispatcher pending)*
geo.track
location
session, stop, interval_seconds, last_sequence, resume_state
android
app.list
apps
—
android, tv
sensor.motion
sensor
result, seconds, guide
android
app.list is rendered by the one shared Android package reader/view as a searchable icon grid;
Zero's own APK is excluded because its declared experiences occupy the launcher’s Zero-app section.
intent (30) — hand off to the OS
Capability
Area
Params
Native on
share.text
share
text
android, ios
share.url
share
url
android, ios
dial.number
comms
number
android, ios
call.number
comms
number
android
sms.compose
comms
number, body
android, ios
email.compose
comms
to, subject, body
android, ios, mac, linux, windows
web.open
web
url
android, ios, mac, linux, windows
map.show
location
query
android, ios
map.navigate
location
query
android, ios
calendar.add
calendar
title, description
android
contacts.pick
contacts
— (returns:true)
android
files.pick
files
mime (returns:true)
android, ios, mac
files.open
files
uri
android, mac, linux, windows
app.launch
apps
package
android
store.open
apps
package
android
settings.open
system
screen
android
speech.recognize
media
— (returns:true)
android
intent.raw
system
action, data, type
android
web.search
web
query
android, ios, mac, linux, windows
whatsapp.chat
comms
number, text
android, ios, mac, linux, windows
calendar.view
calendar
—
android
contacts.add
contacts
name, phone
android
alarm.set
system
hour, minutes, message
android
timer.set
system
seconds, message
android
camera.photo
camera
— (returns:true)
android
camera.video
camera
— (returns:true)
android
mic.record
media
— (returns:true)
android
app.info
apps
package
android
app.uninstall
apps
package
android
wallpaper.set
system
—
android
*Typed extras:* an intent extras value may be {"type":"int|long|bool|float","val":"{1}"} — the shell binds it as that type (string by default). This unlocks alarm/timer/typed-settings intents as pure catalog rows.
effect (14) — a fire-and-forget device effect
Capability
Area
Params
Native on
haptics.vibrate
system
ms
android, ios
clipboard.write
clipboard
text
android, ios, mac, linux, windows
notify.local
notify
title, body
android, ios, mac, linux, windows
toast.show
system
text
android
flashlight.on
system
—
android
flashlight.off
system
—
android
tts.speak
media
text
android, ios, mac, linux, windows
app.pin
apps
app, title, icon
android
a11y.tap
control
xy
android
a11y.swipe
control
path
android
a11y.text
control
text
android
a11y.back
control
—
android
a11y.home
control
—
android
a11y.recents
control
—
android
value (12) — read a value and post it to result
Capability
Area
Params
Native on
clipboard.read
clipboard
—
android, ios, mac, linux, windows
geo.once
location
—
android, ios, mac, web
battery.level
power
—
android, ios, mac, linux, windows
device.info
system
—
android, ios, mac, linux, windows
network.status
system
—
android, ios, mac, linux, windows
biometric.verify
biometric
reason
android
a11y.tree
control
—
android
screen.shot
control
—
android
contacts.all
contacts
—
android, ios, mac
perm.status
permissions
permission
android
perm.request
permissions
permission
android
perm.request.all
permissions
—
android
Adding a new capability
For intent / effect / value the whole change is one catalog row — no DSL and no shell code (as
long as an effect/value reuses an existing key). To add, say, *"open WhatsApp chat"*:
1. Append one line to os/capabilities.jsonl:
json
{"id":"whatsapp.chat","title":"WhatsApp a number","kind":"intent","area":"comms",
"desc":"Open a WhatsApp chat with a phone number.","params":["number"],
"platforms":["android","ios"],
"android":{"action":"android.intent.action.VIEW","data":"https://wa.me/{1}"},
"ios":{"kind":"url","url":"https://wa.me/{1}"},
"fallback":"https://wa.me/{1}"}
2. Emit it from any action: _native_do "whatsapp.chat" "Message on WhatsApp" "/os/result" "15551234" "" "".
That's it. It appears in os.content, gets a live try at os.cap?id=whatsapp.chat, is marked
*native* on the Android/iOS platform pages, runs through each shell's intent dispatcher on a
device, and degrades to https://wa.me/… on the web — with zero engine, shell, or helper edits.
A new effect/value is likewise row-only when it reuses an existing dispatcher key
(vibrate, clipboard.read, …); a genuinely new key adds one branch to that platform's switch.
camera.qr — the bespoke live view
camera.qr is the one built view capability and the template for bespoke views. On Android it is
CameraX preview + frame analysis decoded by ZXing core (pure-JVM — no Play Services, so it works
on de-Googled phones, Android TV, and offline). On the first decode it unbinds the camera and
routes the value exactly like the webview fallback (trust/scan.html):
• a claim/login link (/trust/claim…, or any http(s) URL) → the web door, because the
key-import ceremony needs WebCrypto;
• a device join code (zjoin:<pubkey>, or a link carrying pubkey=) → the node's result
action (trust.scanresult → confirm → trust.approve_device).
A camera-less device or denied permission falls to a message; the scene's webview fallback still
offers paste-the-code. CAMERA is runtime-requested on first use and not required to install (a
camera-less TV still runs). A bespoke view like this is the *only* case that needs native code —
you can't call a live camera or map from JSON — and it costs one native view method + one registry
line; the registry stays uniform. The generic web.view view (a WebView at a url) needs no bespoke
method at all.
One catalog, every shell, every universe — the doctrine
This is not a contacts fix; it is the rule for every device/OS feature, on every shell, across
every universe (every scoped network/fleet on a ZeroID — office, home, a private workspace all run
the same store). A capability is declared once as a catalog row and executed by each shell's
generic per-kind dispatcher. The catalog ships *in the store*, so every node on every network reaches
the same capability by the same name; a shell teaches itself a kind once, and every future
feature of that kind is pure data. There is exactly one place a feature lives (the catalog) and
one place a kind is taught (the dispatcher) — never a bespoke, one-platform path.
Why this rule exists — the contacts lesson. "Add from contacts" was first built the WRONG way: a
bespoke native node plus a hand-written Android-only reader (ContactsPickCapability). It
worked on exactly the one platform someone coded and silently vanished on iOS (no native case)
— the classic "works here, invisible there" failure the *two users, one door* doctrine forbids. The
fix was NOT to hand-write iOS, then mac, then the next platform forever. It was to see the
interaction's shape: "read a list from the device" is the value kind. Reclassified as the
contacts.all value row it became one branch per shell (Android/iOS/mac) on the *existing* value
dispatcher, an automatic /me/invite degrade on web/linux/windows, and the mold for every future
device-list read (photos, calendar, files) — which are now catalog rows, not new native code.
Classify, don't hand-code the platform. Almost every device feature is one of the four kinds
(intent/effect/value/view). A feature that fits a kind is a catalog row → cross-platform for free.
Native code is needed only to teach a shell a genuinely new *kind* — done once, after which every
future feature of that kind is data. A bespoke, single-platform capability is a defect, not a
shortcut (see the reuse-first mandate).
The test that makes it stick. [roadmap/capability-parity/verify-cap-parity.mjs](../roadmap/capability-parity/verify-cap-parity.mjs)
asserts, for every catalog row and every platform it claims in platforms[], that the shell actually
implements it — and flags any bespoke device path that is not a catalog row. One test, every
feature, forever: a feature that skips the catalog, or claims a platform it never wired, fails CI
instead of a user hitting the hole months later.
Honesty — what is code-free vs. what needs a native method
• intent — fully data-driven. A new feature is a catalog row, no shell code.
• effect / value — a *finite keyed set*. A new row reusing an existing key is code-free; a
genuinely new key is one branch in that platform's dispatcher switch.
• view — each bespoke live view (camera, map, AR) needs one native method + one registry
line per platform. This is inherent: you cannot summon a camera preview from JSON. The generic
web.view is the exception (one shared WebView dispatcher).
• Shells build in CI / need a device. The store side above is built and verified here (renders
observed directly). The shells' per-kind dispatchers are added on the existing bridge=/onNative
seams; the mobile APK is an NDK/C++ build that runs in CI (.github/workflows/mobile.yml) and live
hardware features (camera, biometric, torch) need an on-device test. Treat shell changes as
written + structurally checked until a build/device run confirms them — never claimed verified
before that.
Verification & boundaries
• Store / DSL side (verified here): os.content builds the full catalog list from the jsonl;
os.cap emits the real one-shot button (bridge/cap/capkind/p1..3/and/ios/… + the
fallback in action) for intent/effect/value and the live-try native node for view caps;
trust.scan emits the native node (cap/result/and/ios + the webview fallback kid) via
_native_view; the platform pages mark rows native/fallback from platforms[]. Rendered scenes
were inspected directly.
• Shell side (written; CI + device pending): the Android shell implements the wire above — the
generic intent/effect/value dispatchers (NativeOneShot.kt, incl. typed intent extras and the contacts.all value key that retired the bespoke ContactsPickCapability), the
view registry (NativeBridge.kt) with camera.qr (QrScanCapability) + app.list
(NativeAppList.kt), the device-control AccessibilityService (ZeroA11yService.kt — the a11y.*
effect/value keys), and screen capture (ScreenCaptureService.kt — screen.shot via MediaProjection),
plus the matching manifest permissions/services/queries. iOS (SceneRenderer.swift) does intent
share/url + effect vibrate/clipboard.write/notify/tts + value clipboard.read/device.info/battery/network/contacts.all; desktop
(scene_mac.h / main_gtk.c / main_win.cpp) does intent open/exec + effect clipboard.write/notify/tts + value clipboard.read/device.info/battery/network (mac also value contacts.all; linux/windows degrade to the row fallback). All are structurally in place on the existing
seams but are not claimed verified until the CI APK/IPA/desktop build and an on-device run
succeed. Device-specifics to confirm: runtime-permission prompts; the a11y service enablement +
gesture dispatch + node-tree read; and screen.shot's Android-10+/14+ foreground-service timing +
ImageReader rowStride handling.
• New platforms become native by implementing the same per-kind dispatchers and honoring the same
catalog rows — no engine or DSL change. Same capabilities, different native methods: the device
class, re-implemented.