Device Capabilities
Let a server-side module pick photos, capture from the camera, record audio, save files, find Bluetooth devices and check permissions on the user's device, over the same socket as the UI
Device Capabilities
In a Remote UI app your module runs on the server and the UI streams to a thin client. The device capability protocol gives that module access to the hardware in the user's hand: a handler asks for a capability, the client runs it with the platform's own pickers, prompts and hardware, and the result, bytes included, comes back to the handler as a plain value.
module Profile {
Column {
Image(src: "@{state.avatarUrl}")
.width(96)
.height(96)
.cornerRadius(48)
Button { Text("Change photo") }
.onClick(@actions.changePhoto)
Text("@{state.error}").color("#ff6b6b")
}
.gap(12)
.padding(24)
}import { app } from "@hypen-space/core";
export default app
.defineState({ avatarUrl: "", error: "" })
.onAction("changePhoto", async ({ state, context }) => {
const res = await context.device.request("gallery.pick", {
mediaTypes: ["photo"],
maxCount: 1,
});
if (!res.ok) {
// denied, cancelled, unsupported, ...: errors are values, never throws
state.error = res.error.code;
return;
}
const photo = res.value.items[0]; // photo.bytes is a hash-verified Uint8Array
state.avatarUrl = await storage.upload(photo.bytes, photo.contentType);
})
.build();You don't write an upload endpoint, presigned URLs or client-side code. When the user taps the button, the native photo picker opens on their phone (or a file dialog in their browser). The photo then streams to your server over the same WebSocket as the UI. It arrives in chunks, the server paces the transfer, and it checks the SHA-256 hash before your handler sees any bytes.
The server asks, the device decides
A handler can request device work, but the device decides whether it happens:
- Consent comes from the device. Every sensitive operation passes a consent step that the app can't draw, click or hide. This is either the platform's own picker or capture screen, or a dialog owned by the device host that names the server asking.
- A request isn't tied to a UI element. It can come from any action or lifecycle handler. When the platform needs a fresh user gesture, the device host shows its own Continue button. Your button doesn't need to stay visible or enabled.
- Recordings and scans stay visible. While a microphone recording or a Bluetooth scan runs, the device shows an indicator with a Stop button.
- A capability is offered only when it can work. A client advertises only
what it can actually do: the hardware exists, the app declared the
permission, and the indicator can be shown.
context.device.supports(...)tells your module what was negotiated. - Work belongs to the screen that asked for it. When the user navigates away, pending operations are cancelled, and a late result is thrown away instead of uploaded.
See Consent & Security for the full model.
How it works
server (module handler) client (DeviceHost)
─────────────────────── ───────────────────
hello ◄─────────── advertises what this device can do (camera, picker, …)
sessionAck ───────► picks a revision of each capability both sides know
context.device.request("gallery.pick", …)
deviceRequest ──────────────────────────────► validate, check cooldowns
consent: system picker or
host dialog (Continue/Cancel)
◄──────── blobStart + binary frames (≤ 64 KiB each, paced by server credit)
◄──────── deviceResponse { items: [{ bytes, sha256, … }] }
broker verifies byte count + SHA-256
handler gets { ok: true, value: { items: [{ bytes: Uint8Array, … }] } }- Negotiation. When the client connects, its
helloadvertises the capabilities it can perform. The server picks one revision of each capability that both sides support. The client can update its list while connected (for example when the app goes to the background), andsupports()follows those updates. - Request. Your handler calls
context.device.request(...), or the equivalent in Go, Kotlin, Swift or Rust. The server validates the parameters against the capability's schema before it sends anything, so a bad call fails immediately withinvalidParams. - Consent and execution. The client's DeviceHost checks its own rules (cooldowns, one prompt at a time), gets consent, and calls the platform API.
- Transfer. Uploaded bytes travel as binary WebSocket frames of up to 64 KiB on the connection that carries the UI. The server grants credit to pace them, so a large upload can't starve UI updates or flood server memory.
- Verification. The server checks that the item set, sizes and SHA-256
hashes match what the client declared. Only then does your handler get the
bytes. A mismatch fails the request with
invalidParams. - Liveness. The server renews each running operation every 5 seconds. If
a connection goes silent for 15 seconds, both sides stop the operation
(
connectionLost). Nothing is replayed after a reconnect.
All server SDKs share the server side of the protocol: request tracking, leases, deadlines, credit, upload verification and memory budgets. It is written once in Rust, inside the Hypen engine; the TypeScript, Go, Kotlin and Swift servers run that same code through their existing engine bindings, and the Rust SDK links it directly. Each SDK wraps it in an API that fits the language.
Capabilities
| Capability | What it does |
|---|---|
gallery.pick | Pick up to 16 photos or videos from the device library |
file.pick | Pick up to 16 documents, with MIME-type filters |
file.save | Send a file to the device, where the user chooses the destination |
camera.capture | Take one photo or record one video in the host's capture UI |
mic.record | Stream a PCM16 recording to the handler as it's captured |
bluetooth.select | Let the user choose one nearby Bluetooth LE device (identity only) |
bluetooth.scan | Stream nearby Bluetooth LE advertisements (iOS and Android) |
permission.query | Read the status of one permission without prompting |
permission.request | Ask the user for one permission |
See the Capability Reference for parameters, results and platform support.
Where it runs
| Side | Supported |
|---|---|
| Server | TypeScript (Bun/Node RemoteServer), Cloudflare Workers (@hypen-space/cf), Go, Kotlin, Swift, Rust (hypen-server RemoteSession) |
| Client | Web (@hypen-space/device-web), iOS (DeviceHost.iOS()), Android (AndroidDeviceHost.create()), desktop (hypen-renderer-desktop remote mode) |
Device capabilities need a Remote UI connection. A local app that embeds the
engine in the same process (for example the desktop renderer's in-process
mode) has no device plane: every call answers unavailable
(device-disabled).
The protocol is version 1 and provisional. It is implemented and tested in every SDK listed above and in real Chromium. The native iOS and Android hosts have not yet been validated on physical devices, and the wire format may change before it is frozen. See Limits & Known Limitations.
In this section
- Enabling on the Server: choose who may connect, tune or opt out of the device plane, in each server SDK
- Client Setup: web, iOS, Android and desktop device hosts, required
Info.plistkeys and manifest permissions - Capability Reference: every capability's parameters, results and platform behavior
- Requests & Streams:
request,stream,save,supports, options, cancellation - Errors: the ten error codes and when each one happens
- Lifetimes & Navigation: how device work follows module activation
- Consent & Security: consent gates, indicators, cooldowns, connection admission
- Limits & Known Limitations: sizes, budgets, deadlines, and what isn't done yet