Capability Reference
Parameters, results, consent, deadlines and platform support for gallery.pick, file.pick, file.save, camera.capture, mic.record, bluetooth.select, bluetooth.scan, permission.query and permission.request
Capability Reference
Every capability has a name and a revision number. All capabilities are at
revision 1. The server validates parameters against the schema before
sending anything, so a bad argument fails locally with invalidParams, and
platformDetail names the offending field (for example
params $.services[0]: …). The server validates results too, before your
handler sees them.
At a glance
| Capability | Kind | Consent gate | Max deadline | Web | iOS | Android | Desktop |
|---|---|---|---|---|---|---|---|
gallery.pick | request, upload | The picker | 300 s | Yes | Yes | Yes | Yes |
file.pick | request, upload | The picker | 300 s | Yes | Yes | Yes | Yes |
file.save | request, download | Host dialog, then destination picker | 300 s | Yes | Yes | Yes | Yes |
camera.capture | request, upload | The host's capture UI | 600 s | If getUserMedia | Camera + key | Camera | Camera |
mic.record | stream, upload | Host dialog; indicator with Stop | 600 s | If getUserMedia | Mic + key + indicator | Mic + permission + indicator | Mic + indicator |
bluetooth.select | request | Host chooser | 300 s | Chromium only | Key | BLE + permission | BLE |
bluetooth.scan | stream, JSON events | Host dialog (grant can persist, with expiry); indicator | 600 s | No | Key + indicator | BLE + permission + indicator | BLE + indicator |
permission.query | request | None, never prompts | 30 s | Yes | Yes | Yes | Yes |
permission.request | request | Host dialog, then the OS prompt | 300 s | Yes | Yes | Yes | Yes |
"Key" means the Info.plist usage description, and "permission" means the
manifest entry. "Desktop" is the native desktop renderer
(hypen-renderer-desktop): the file capabilities go through the operating
system's file dialogs, and camera, microphone and Bluetooth through the
desktop's own capture panel, chooser and indicators, drawn over the app
window. Camera, microphone and Bluetooth are cargo features that are on by
default; a build without one doesn't offer it (unsupported). With no camera,
microphone or adapter present, requests fail with unavailable. See Client Setup for exactly
what each platform requires.
If you pass no timeoutMs, the deadline is 300 s, clamped to the maximum in
the table. Every request is also bounded by the client's own ceiling (300 s
by default on the web host).
In every SDK, uploaded items reach your handler as verified bytes instead of the wire's size and hash. The server has already checked the item count, each size, and each SHA-256 before your handler runs.
gallery.pick
Pick photos and/or videos from the device's media library.
| Param | Type | Notes |
|---|---|---|
mediaTypes | ("photo" | "video")[] | 1–2 entries, no duplicates |
maxCount | integer | 1–16 |
Result: { items: [{ channel, contentType, bytes }] }, with up to 16
items of up to 64 MiB each.
const res = await context.device.request("gallery.pick", { mediaTypes: ["photo"], maxCount: 4 });
if (res.ok) for (const item of res.value.items) await save(item.bytes, item.contentType);| SDK | Call |
|---|---|
| Go | ctx.Device().Gallery().Pick(ctx, device.GalleryPickParams{MediaTypes: []device.MediaType{device.MediaTypePhoto}, MaxCount: 4}) → []device.Blob |
| Kotlin | ctx.device.gallery.pick(GalleryPickParams(listOf(MediaType.PHOTO), 4)) → DeviceResult<List<VerifiedBlob>> |
| Swift | await ctx.device.gallery.pick([.photo], maxCount: 4) → DeviceResult<DevicePickedItems> |
| Rust | ctx.device().gallery_pick(&[MediaType::Photo], 4) → DeviceCall<Vec<DeviceBlob>> |
Platforms:
- Web: the host dialog comes first, because the browser needs a fresh
gesture. Then
<input type=file>opens with animage/*orvideo/*filter. Cancel in the host dialog isdeniedand starts a cooldown. Dismissing the browser's picker iscancelled(picker-dismissed). - iOS: PHPicker. It runs out of process and needs no photo-library permission.
- Android: the system Photo Picker, falling back to
ACTION_OPEN_DOCUMENT. - Desktop: the OS open dialog, filtered to image and/or video file types
per
mediaTypes; its title names the server origin. Files of another type are skipped. Files stream from disk as credit allows.
file.pick
Pick documents, optionally filtered by type.
| Param | Type | Notes |
|---|---|---|
accept | string[] | Up to 32 MIME types or patterns ("application/pdf", "image/*"), each up to 128 characters. [] means any type. |
maxCount | integer | 1–16 |
Result: { items: [{ channel, name, contentType, bytes }] }. Unlike
gallery.pick, each item carries its file name.
const res = await context.device.request("file.pick", { accept: ["application/pdf"], maxCount: 1 });
if (res.ok) {
const [doc] = res.value.items;
state.fileName = doc.name;
}| SDK | Call |
|---|---|
| Go | ctx.Device().Files().Pick(ctx, device.FilePickParams{Accept: []string{"application/pdf"}, MaxCount: 1}) → []device.Blob (with Name) |
| Kotlin | ctx.device.files.pick(FilePickParams(listOf("application/pdf"), 1)) → DeviceResult<List<VerifiedBlob>> |
| Swift | await ctx.device.files.pick(accept: ["application/pdf"], maxCount: 1) → DeviceResult<DevicePickedItems> |
| Rust | ctx.device().file_pick(&["application/pdf"], 1) → DeviceCall<Vec<DeviceBlob>> (with name) |
Platforms: web <input type=file> (after the host dialog), iOS
UIDocumentPickerViewController (open, security-scoped), Android
ACTION_OPEN_DOCUMENT, desktop the OS open dialog (multi-select when
maxCount > 1; accept becomes the dialog's file-type filter). The content
type comes from the file extension on the desktop.
file.save
Send bytes to the device. The user chooses where they go. This is the one capability where data flows from the server to the device.
const res = await context.device.save(pdfBytes, {
name: "invoice-1042.pdf",
contentType: "application/pdf",
});
if (res.ok) state.saved = res.value.bytesWritten;save(bytes, { name, contentType, timeoutMs?, signal? }) resolves to
{ bytesWritten }. The file must be 1 byte to 64 MiB. name can be up to 512
characters, and contentType up to 256. The SDK computes the SHA-256 and
announces the file for you.
| SDK | Call |
|---|---|
| Go | ctx.Device().Files().Save(ctx, "invoice.pdf", "application/pdf", data) → *device.SaveResult{BytesWritten} |
| Kotlin | ctx.device.files.save(bytes, "invoice.pdf", "application/pdf") → DeviceResult<FileSaveResult> |
| Swift | await ctx.device.files.save(data, name: "invoice.pdf", contentType: "application/pdf") → DeviceResult<FileSaveResult> |
| Rust | ctx.device().save("invoice.pdf", "application/pdf", bytes) → DeviceCall<SaveReceipt> |
How it's delivered: the device first shows its consent and destination picker. Only after that does it grant credit, at most 256 KiB at a time, and the server sends 64 KiB frames within that credit. The device verifies the size and hash and finishes writing before it reports success. A device that never grants credit receives nothing, and the request ends at its deadline.
Platforms:
- Web:
showSaveFilePickerstreams straight to the file, which commits only on close, so a failure leaves no partial file. Where that API is missing, the web host falls back to a Blob and an<a download>link, capped bymaxDownloadBytes. In that casebytesWrittenmeans the bytes were handed to the browser's download manager, not that the user kept the file. - iOS: the document export picker, fed from a temporary file.
- Android:
ACTION_CREATE_DOCUMENT. The partial file is deleted if the write fails. - Desktop: the OS save dialog (titled with the origin and the file name) is the consent and destination gate. Bytes go to a temporary file beside the destination, which is renamed into place only after the size and SHA-256 verify; on failure, cancellation or disconnect the temporary file is deleted and an existing destination is left untouched.
camera.capture
Take one photo or record one video through the host's own capture UI, which serves as the consent gate.
| Param | Type | Notes |
|---|---|---|
mode | "photo" or "video" | Required |
facing | "front" or "back" | Optional; the host may fall back when the device has one camera |
maxDurationMs | integer | Video only, 1–600000. A photo with maxDurationMs is a type error in TypeScript, and invalidParams everywhere. |
Result: exactly one item, at most 64 MiB. A photo is image/jpeg or
image/heic. A video is video/mp4, video/quicktime or video/webm.
const res = await context.device.camera.capture({ mode: "photo", facing: "back" });
if (res.ok) state.photoUrl = await storage.upload(res.value.items[0].bytes);
const clip = await context.device.camera.capture({ mode: "video", maxDurationMs: 15_000 });| SDK | Call |
|---|---|
| Go | ctx.Device().Camera().Photo(ctx, device.CameraFacingBack), Camera().Video(ctx, facing, maxDurationMs) or Camera().Capture(ctx, params) → device.Blob |
| Kotlin | ctx.device.camera.capture(CameraCaptureParams(CaptureMode.PHOTO, CameraFacing.BACK)) → DeviceResult<VerifiedBlob> |
| Swift | await ctx.device.camera.capture(.photo, facing: .back) → DeviceResult<DeviceReceivedBlob> |
| Rust | ctx.device().camera_capture(CameraCaptureParams { mode: CaptureMode::Photo, facing: Some(CameraFacing::Back), max_duration_ms: None }) → DeviceCall<DeviceBlob> |
A refused OS camera (or microphone) permission is denied. Closing the
capture UI without capturing is cancelled. Video on native hosts also needs
the microphone permission.
Platforms:
- Web: a host dialog with a live preview and Capture, Record/Stop and
Cancel buttons. A photo is the preview frame encoded as JPEG. A video is
MediaRecorderoutput (webm), streamed while it records, so its size isn't known in advance. - iOS:
UIImagePickerController. - Android: the system
TakePicture/CaptureVideoactivities, writing into the renderer's privateFileProvider. - Desktop: a capture panel over the app window with a live preview and
Capture, Record/Stop and Cancel. A photo is
image/jpeg; a video is H.264 in a fragmentedvideo/mp4, streamed while it records, with no audio track.
mic.record
Record audio and stream it to your handler while it's being captured.
| Param | Type | Notes |
|---|---|---|
format | "pcm16" | Required; the only format in revision 1 |
sampleRate | integer | 8000–192000 Hz |
channels | 1 or 2 | Optional, defaults to 1. Stereo is interleaved. |
maxDurationMs | integer | Optional, 1–600000 |
Data: little-endian 16-bit PCM (audio/L16) at the requested rate,
delivered to your callback in order.
Result: { durationMs, item: { channel: 0, contentType, bytes, sha256 } }.
The server verifies bytes and sha256 against everything it delivered.
const recording = context.device.mic.record(
{ format: "pcm16", sampleRate: 16000, maxDurationMs: 60_000 },
(chunk) => transcriber.push(chunk), // may return a promise; the device waits for it
);
const res = await recording.settled;
if (res.ok) state.transcript = await transcriber.finish();| SDK | Call |
|---|---|
| Go | ctx.Device().Mic().Record(ctx, device.MicRecordParams{SampleRate: 16000}, func(chunk []byte) error { … }) → *device.MicRecordResult |
| Kotlin | ctx.device.mic.record(MicRecordParams(16000, MicFormat.PCM16)) { chunk -> … }.await() |
| Swift | let rec = ctx.device.mic.record(sampleRate: 16000), then for await chunk in rec { … } and await rec.result() |
| Rust | ctx.device().mic_record(params)?.for_each(|state: &mut S, item| …) with StreamItem::Data { bytes, .. } chunks, then StreamItem::End(result) |
How a recording ends:
- Normally, with a success result containing what was captured: the user
taps Stop on the recording indicator,
maxDurationMsis reached, or the app or page goes to the background. - With an error, discarding the recording: you call
cancel(), the deadline expires, the owner deactivates, the lease is lost, or the permission is revoked. - With
throttled: your consumer is so slow that the device's bounded capture buffer fills while it waits for credit.
Platforms: web getUserMedia with an AudioWorklet (or a ScriptProcessor
fallback) that resamples to the requested rate, iOS AVAudioEngine with
AVAudioConverter, Android AudioRecord. Every platform shows the host's
recording indicator for the whole recording.
bluetooth.select
Let the user choose one nearby Bluetooth LE device, and get its identity. There is no GATT access in revision 1.
| Param | Type | Notes |
|---|---|---|
services | string[] | Optional, 1–16 unique service UUIDs in canonical lowercase 128-bit form |
namePrefix | string | Optional, 1–64 characters |
A 16-bit SIG id must be sent expanded: heart rate 0x180d is
"0000180d-0000-1000-8000-00805f9b34fb". Kotlin has
BluetoothSelectParams.expandShortUuid(0x180d), and Go has
ExpandBluetoothUUID16 in remote/device.
Result: { device: { id, name? } }. The id is 1–128 characters.
const res = await context.device.bluetooth.select({
services: ["0000180d-0000-1000-8000-00805f9b34fb"],
});
if (res.ok) state.sensorId = res.value.device.id;| SDK | Call |
|---|---|
| Go | ctx.Device().Bluetooth().Select(ctx, device.BluetoothSelectParams{Services: []string{hr}}) → device.SelectedBluetoothDevice |
| Kotlin | ctx.device.bluetooth.select(BluetoothSelectParams(services = listOf(hr))) → DeviceResult<SelectedBluetoothDevice> |
| Swift | await ctx.device.bluetooth.select(services: [hr]) → DeviceResult<SelectedBluetoothDevice> |
| Rust | ctx.device().bluetooth_select(BluetoothSelectParams { services: Some(vec![hr]), name_prefix: None }) → DeviceCall<SelectedBluetoothDevice> |
Dismissing the chooser is cancelled.
Platforms:
- Web: the host dialog, then the browser's Web Bluetooth chooser
(
navigator.bluetooth.requestDevice). Web Bluetooth exists only in Chromium-based browsers. Elsewhere the capability isn't offered, and a request returnsunsupported. - iOS, Android and desktop: a host-owned chooser listing a live scan. While it's open, the chooser itself is the visible indicator.
bluetooth.scan
Stream Bluetooth LE advertisements as the device sees them. Native only. The web doesn't offer it.
Params: {}.
Events: { device: { id, name?, rssi } } (rssi is an integer).
Result: {}.
const scan = context.device.stream("bluetooth.scan", {}, {}, (event) => {
state.nearby[event.device.id] = { name: event.device.name ?? "", rssi: event.device.rssi };
});
// later, e.g. from another action or a timer:
scan.cancel();| SDK | Call |
|---|---|
| Go | ctx.Device().Bluetooth().Scan(ctx, func(d device.BluetoothDevice) error { … }). Return device.ErrStop to end it. |
| Kotlin | ctx.device.bluetooth.scan { d -> … }, or ctx.device.events(Capability.BLUETOOTH_SCAN, BluetoothScanParams).take(10).collect { … } |
| Swift | for await d in ctx.device.bluetooth.scan() { … } |
| Rust | ctx.device().bluetooth_scan()?.for_each(|state: &mut S, item| …); keep the returned DeviceHandle to cancel() it |
A scan runs until you cancel it, the user taps Stop on the indicator
(cancelled), the deadline passes (600 s at most), or the owning module
deactivates. Consent for scanning is persistable: the host can remember a
grant for a limited time (24 hours on Android and 7 days on iOS by default),
and then asks again. Grants persist only for authenticated wss:// origins.
The indicator stays visible for the whole scan either way. On Android the adapter must be on, and
on some API levels Location Services too. Otherwise the request answers
unavailable.
permission.query / permission.request
Read or request one permission from a closed set.
| Param | Type | Notes |
|---|---|---|
permission | "camera", "microphone", "photos", "location", "notifications", "bluetooth", or "contacts" | Anything else (a typo, an alias such as geolocation, a different case) is a compile error in TypeScript and invalidParams everywhere |
Result: { status: "granted" | "denied" | "prompt" }.
permission.query never prompts and returns the live status.
permission.request shows the host's consent dialog, then the real OS
prompt. If the permission has already been decided, it answers without a
dialog.
const cam = await context.device.permissions.query("camera");
if (cam.ok && cam.value.status === "prompt") {
await context.device.permissions.request("camera");
}
context.device.permissions.query("camra"); // type error| SDK | Call |
|---|---|
| Go | ctx.Device().Permissions().Query(ctx, device.PermissionCamera) / .Request(...) → device.PermissionStatus |
| Kotlin | ctx.device.permissions.query(Permission.CAMERA) / .request(...) → DeviceResult<PermissionStatus> |
| Swift | await ctx.device.permissions.query(.camera) / .request(...) → DeviceResult<PermissionResult> |
| Rust | ctx.device().permission_query(Permission::Camera) / permission_request(...) → DeviceCall<PermissionStatus> |
Permission status is not the same as capability support. You can pick a
photo on iOS with no photo-library permission at all, because PHPicker needs
none. Use supports() to find out whether a capability can run, and
permission.* only when your app needs the OS permission itself.
Per platform:
| Permission | Web | iOS | Android |
|---|---|---|---|
camera, microphone | Permissions API; request calls getUserMedia and stops the tracks at once | System API (needs the usage key) | CAMERA / RECORD_AUDIO |
location | Permissions API; request calls getCurrentPosition | NSLocationWhenInUseUsageDescription | Fine or coarse location |
notifications | Notification.requestPermission | System API | POST_NOTIFICATIONS on API 33+. Before API 33 there is no runtime permission, and the status reflects whether notifications are enabled for the app. |
photos | Always granted (the file picker needs none) | NSPhotoLibraryUsageDescription | READ_MEDIA_* on API 33+, READ_EXTERNAL_STORAGE before that |
bluetooth | prompt where Web Bluetooth exists, otherwise unsupported | NSBluetoothAlwaysUsageDescription | BLUETOOTH_SCAN on API 31+, location before that |
contacts | unsupported | NSContactsUsageDescription | READ_CONTACTS |
A host that can't represent a permission at all answers unsupported, with
the permission name as platformDetail. A native permission whose key or
manifest entry is missing answers unavailable with not-declared:<name>.
Client Setup
Attach a device host to the web, iOS, Android and desktop clients, declare the permissions each capability needs, and use the fake host during development
Requests & Streams
The handler-side device API — supports, request, stream, save, the result shape, options, abort signals, streams with onEvent and onData, and cancellation, with Go, Kotlin, Swift and Rust equivalents