HypenHypen
Device Capabilities

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

CapabilityKindConsent gateMax deadlineWebiOSAndroidDesktop
gallery.pickrequest, uploadThe picker300 sYesYesYesYes
file.pickrequest, uploadThe picker300 sYesYesYesYes
file.saverequest, downloadHost dialog, then destination picker300 sYesYesYesYes
camera.capturerequest, uploadThe host's capture UI600 sIf getUserMediaCamera + keyCameraCamera
mic.recordstream, uploadHost dialog; indicator with Stop600 sIf getUserMediaMic + key + indicatorMic + permission + indicatorMic + indicator
bluetooth.selectrequestHost chooser300 sChromium onlyKeyBLE + permissionBLE
bluetooth.scanstream, JSON eventsHost dialog (grant can persist, with expiry); indicator600 sNoKey + indicatorBLE + permission + indicatorBLE + indicator
permission.queryrequestNone, never prompts30 sYesYesYesYes
permission.requestrequestHost dialog, then the OS prompt300 sYesYesYesYes

"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.

ParamTypeNotes
mediaTypes("photo" | "video")[]1–2 entries, no duplicates
maxCountinteger1–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);
SDKCall
Goctx.Device().Gallery().Pick(ctx, device.GalleryPickParams{MediaTypes: []device.MediaType{device.MediaTypePhoto}, MaxCount: 4}) → []device.Blob
Kotlinctx.device.gallery.pick(GalleryPickParams(listOf(MediaType.PHOTO), 4)) → DeviceResult<List<VerifiedBlob>>
Swiftawait ctx.device.gallery.pick([.photo], maxCount: 4) → DeviceResult<DevicePickedItems>
Rustctx.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 an image/* or video/* filter. Cancel in the host dialog is denied and starts a cooldown. Dismissing the browser's picker is cancelled (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.

ParamTypeNotes
acceptstring[]Up to 32 MIME types or patterns ("application/pdf", "image/*"), each up to 128 characters. [] means any type.
maxCountinteger1–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;
}
SDKCall
Goctx.Device().Files().Pick(ctx, device.FilePickParams{Accept: []string{"application/pdf"}, MaxCount: 1}) → []device.Blob (with Name)
Kotlinctx.device.files.pick(FilePickParams(listOf("application/pdf"), 1)) → DeviceResult<List<VerifiedBlob>>
Swiftawait ctx.device.files.pick(accept: ["application/pdf"], maxCount: 1) → DeviceResult<DevicePickedItems>
Rustctx.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.

SDKCall
Goctx.Device().Files().Save(ctx, "invoice.pdf", "application/pdf", data) → *device.SaveResult{BytesWritten}
Kotlinctx.device.files.save(bytes, "invoice.pdf", "application/pdf") → DeviceResult<FileSaveResult>
Swiftawait ctx.device.files.save(data, name: "invoice.pdf", contentType: "application/pdf") → DeviceResult<FileSaveResult>
Rustctx.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: showSaveFilePicker streams 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 by maxDownloadBytes. In that case bytesWritten means 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.

ParamTypeNotes
mode"photo" or "video"Required
facing"front" or "back"Optional; the host may fall back when the device has one camera
maxDurationMsintegerVideo 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 });
SDKCall
Goctx.Device().Camera().Photo(ctx, device.CameraFacingBack), Camera().Video(ctx, facing, maxDurationMs) or Camera().Capture(ctx, params) → device.Blob
Kotlinctx.device.camera.capture(CameraCaptureParams(CaptureMode.PHOTO, CameraFacing.BACK)) → DeviceResult<VerifiedBlob>
Swiftawait ctx.device.camera.capture(.photo, facing: .back) → DeviceResult<DeviceReceivedBlob>
Rustctx.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 MediaRecorder output (webm), streamed while it records, so its size isn't known in advance.
  • iOS: UIImagePickerController.
  • Android: the system TakePicture / CaptureVideo activities, writing into the renderer's private FileProvider.
  • 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 fragmented video/mp4, streamed while it records, with no audio track.

mic.record

Record audio and stream it to your handler while it's being captured.

ParamTypeNotes
format"pcm16"Required; the only format in revision 1
sampleRateinteger8000–192000 Hz
channels1 or 2Optional, defaults to 1. Stereo is interleaved.
maxDurationMsintegerOptional, 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();
SDKCall
Goctx.Device().Mic().Record(ctx, device.MicRecordParams{SampleRate: 16000}, func(chunk []byte) error { … }) → *device.MicRecordResult
Kotlinctx.device.mic.record(MicRecordParams(16000, MicFormat.PCM16)) { chunk -> … }.await()
Swiftlet rec = ctx.device.mic.record(sampleRate: 16000), then for await chunk in rec { … } and await rec.result()
Rustctx.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, maxDurationMs is 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.

ParamTypeNotes
servicesstring[]Optional, 1–16 unique service UUIDs in canonical lowercase 128-bit form
namePrefixstringOptional, 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;
SDKCall
Goctx.Device().Bluetooth().Select(ctx, device.BluetoothSelectParams{Services: []string{hr}}) → device.SelectedBluetoothDevice
Kotlinctx.device.bluetooth.select(BluetoothSelectParams(services = listOf(hr))) → DeviceResult<SelectedBluetoothDevice>
Swiftawait ctx.device.bluetooth.select(services: [hr]) → DeviceResult<SelectedBluetoothDevice>
Rustctx.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 returns unsupported.
  • 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();
SDKCall
Goctx.Device().Bluetooth().Scan(ctx, func(d device.BluetoothDevice) error { … }). Return device.ErrStop to end it.
Kotlinctx.device.bluetooth.scan { d -> … }, or ctx.device.events(Capability.BLUETOOTH_SCAN, BluetoothScanParams).take(10).collect { … }
Swiftfor await d in ctx.device.bluetooth.scan() { … }
Rustctx.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.

ParamTypeNotes
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
SDKCall
Goctx.Device().Permissions().Query(ctx, device.PermissionCamera) / .Request(...) → device.PermissionStatus
Kotlinctx.device.permissions.query(Permission.CAMERA) / .request(...) → DeviceResult<PermissionStatus>
Swiftawait ctx.device.permissions.query(.camera) / .request(...) → DeviceResult<PermissionResult>
Rustctx.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:

PermissionWebiOSAndroid
camera, microphonePermissions API; request calls getUserMedia and stops the tracks at onceSystem API (needs the usage key)CAMERA / RECORD_AUDIO
locationPermissions API; request calls getCurrentPositionNSLocationWhenInUseUsageDescriptionFine or coarse location
notificationsNotification.requestPermissionSystem APIPOST_NOTIFICATIONS on API 33+. Before API 33 there is no runtime permission, and the status reflects whether notifications are enabled for the app.
photosAlways granted (the file picker needs none)NSPhotoLibraryUsageDescriptionREAD_MEDIA_* on API 33+, READ_EXTERNAL_STORAGE before that
bluetoothprompt where Web Bluetooth exists, otherwise unsupportedNSBluetoothAlwaysUsageDescriptionBLUETOOTH_SCAN on API 31+, location before that
contactsunsupportedNSContactsUsageDescriptionREAD_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>.