HypenHypen
Device Capabilities

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, … }] } }
  1. Negotiation. When the client connects, its hello advertises 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), and supports() follows those updates.
  2. 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 with invalidParams.
  3. Consent and execution. The client's DeviceHost checks its own rules (cooldowns, one prompt at a time), gets consent, and calls the platform API.
  4. 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.
  5. 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.
  6. 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

CapabilityWhat it does
gallery.pickPick up to 16 photos or videos from the device library
file.pickPick up to 16 documents, with MIME-type filters
file.saveSend a file to the device, where the user chooses the destination
camera.captureTake one photo or record one video in the host's capture UI
mic.recordStream a PCM16 recording to the handler as it's captured
bluetooth.selectLet the user choose one nearby Bluetooth LE device (identity only)
bluetooth.scanStream nearby Bluetooth LE advertisements (iOS and Android)
permission.queryRead the status of one permission without prompting
permission.requestAsk the user for one permission

See the Capability Reference for parameters, results and platform support.

Where it runs

SideSupported
ServerTypeScript (Bun/Node RemoteServer), Cloudflare Workers (@hypen-space/cf), Go, Kotlin, Swift, Rust (hypen-server RemoteSession)
ClientWeb (@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