HypenHypen
Device Capabilities

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

Requests & Streams

Handlers reach the device through a device context. The examples on this page are TypeScript, and the last section maps each concept to Go, Kotlin, Swift and Rust.

SDKWhere the device context comes from
TypeScriptcontext.device in action handlers, and context?.device in lifecycle handlers
Goctx.Device() (never nil)
Kotlinctx.device in onActionAsync, and context.device in lifecycle handlers
Swiftctx.device in onActionAsync, and the device argument of onActivatedAsync
Rustctx.device() on the handler's GlobalContext (remote sessions always pass one), or session.device(module) outside handlers

The context belongs to the invocation that received it. It carries that module instance's current activation and the dispatch's origin. You can't hand it to another module or use it to start work for a different owner.

supports()

context.device.supports("camera.capture"); // boolean

supports() reports negotiated, live support: the client offers the capability right now, and the server selected a revision of it. It isn't a permission grant, and it changes as the client updates its capability list, for example while the app is in the background. An update can only withdraw or restore a capability negotiated when the connection opened, never add a new one; that waits for the next connection. Use it to decide whether to show a "Take photo" button:

.onActivated(async (state, context) => {
  state.canScan = context?.device.supports("bluetooth.scan") ?? false;
})

request()

request<C extends UnaryCapability>(
  capability: C,
  params: ParamsOf<C>,
  init?: DeviceRequestInit,
): Promise<DeviceResult<ResultOf<C>>>

request() runs one operation that has a single result. It is typed per capability: an unknown capability name, a misspelled permission, or a photo with maxDurationMs is a compile-time error. Streams (mic.record, bluetooth.scan) and file.save aren't accepted here. Use stream() and save() for those.

The wrappers are thin aliases for the same call:

WrapperSame as
device.camera.capture(params, init?)request("camera.capture", params, init)
device.bluetooth.select(params?, init?)request("bluetooth.select", params ?? {}, init)
device.permissions.query(permission, init?)request("permission.query", { permission }, init)
device.permissions.request(permission, init?)request("permission.request", { permission }, init)
device.mic.record(params, onData, init?)stream("mic.record", params, init ?? {}, { onData })

For capability names that are only known at runtime, requestUntyped(name, params, init?) and streamUntyped(name, params, init, consumer) skip the compile-time checks. The server still validates the name and params before it sends anything.

The result

Every device call resolves to a value and never throws:

type DeviceResult<T> =
  | { ok: true; value: T; simulated?: true }
  | { ok: false; error: { code: DeviceErrorCode; platformDetail?: string } };
  • error.code is one of the ten error codes.
  • platformDetail is short diagnostic text, such as "picker-dismissed" or "not-declared:camera". Log it, but don't branch on it: it isn't portable across platforms.
  • simulated: true means a development fake produced the result.

For uploads, value carries verified bytes instead of the wire's size and hash. Each item is { channel, contentType, bytes: Uint8Array }, plus name for file.pick. The server has already checked the item set, every size and every SHA-256 against what the client declared.

const res = await context.device.request("gallery.pick", { mediaTypes: ["photo", "video"], maxCount: 8 });
if (!res.ok) {
  if (res.error.code !== "cancelled") state.error = res.error.code;
  return;
}
for (const item of res.value.items) {
  await storage.put(crypto.randomUUID(), item.bytes, { contentType: item.contentType });
}

Device results are untrusted input. A matching hash proves the bytes arrived intact. It doesn't prove the file is really a JPEG, or that the client is honest. Validate content before you use it, as you would with any upload.

Options

Every call takes an optional init object:

OptionMeaning
timeoutMsOverall deadline. Defaults to 300 s, clamped to the capability's maximum.
signalAn AbortSignal. Aborting it cancels the request, which settles as cancelled. If the signal is already aborted, nothing is sent.
initialCreditInitial upload credit (bytes for uploads, events for JSON streams), clamped to the capability's maximum. The defaults are 256 KiB and 64 events. 0 is refused with invalidParams.
lifetime"activation" (the default). No revision-1 capability allows "background", so that value returns unsupported. See Lifetimes.
// One controller per module instance (per session), keyed by its state object.
const pending = new WeakMap<object, AbortController>();

app
  .defineState({ status: "" })
  .onAction("pickPhoto", async ({ state, context }) => {
    const controller = new AbortController();
    pending.set(state, controller);
    const res = await context.device.request(
      "gallery.pick",
      { mediaTypes: ["photo"], maxCount: 1 },
      { timeoutMs: 60_000, signal: controller.signal },
    );
    state.status = res.ok ? "picked" : res.error.code; // "cancelled" after abort
  })
  .onAction("cancelPick", ({ state }) => pending.get(state)?.abort());

Keep the handler waiting

A request belongs to the handler that started it. When an action or lifecycle handler returns, any request it started that is still pending is cancelled, because nothing is left to receive the result. Always await device calls inside the handler:

// Wrong: the handler returns at once, and the request is cancelled.
.onAction("pick", ({ context }) => {
  void context.device.request("gallery.pick", { mediaTypes: ["photo"], maxCount: 1 });
})

// Right
.onAction("pick", async ({ state, context }) => {
  const res = await context.device.request("gallery.pick", { mediaTypes: ["photo"], maxCount: 1 });
  // ...
})

Received upload bytes count toward the connection's memory budget until the handler returns. Copy anything you want to keep, such as uploading it to storage, before the handler finishes. Streams aren't tied to the handler this way: they run until they end or their module deactivates.

Your state may change while a handler waits for the device. Other actions can run, and the user can navigate away and back. Re-check it after the await.

save()

save(bytes: Uint8Array, opts: {
  name: string;
  contentType: string;
  timeoutMs?: number;
  signal?: AbortSignal;
}): Promise<DeviceResult<{ bytesWritten: number }>>

save() runs file.save. The SDK takes a copy of bytes, so you can reuse the buffer right away. It then computes the SHA-256, announces the file, and sends the data only after the user has chosen a destination. The result succeeds only if the device reports writing exactly bytes.byteLength bytes.

.onAction("exportCsv", async ({ state, context }) => {
  const csv = new TextEncoder().encode(toCsv(state.rows));
  const res = await context.device.save(csv, { name: "report.csv", contentType: "text/csv" });
  state.exported = res.ok;
})

stream()

// JSON events (bluetooth.scan)
stream(capability, params, init, onEvent: (event) => void | Promise<unknown>): DeviceStreamHandle
// Bytes (mic.record)
stream(capability, params, init, consumer: { onData(chunk: Uint8Array): void | Promise<unknown> }): DeviceStreamHandle

A stream returns a handle straight away:

interface DeviceStreamHandle<R> {
  readonly id: number | null;                 // null when refused locally
  readonly settled: Promise<DeviceResult<R>>; // exactly one outcome; never rejects
  cancel(): void;                             // idempotent
}
  • Events and bytes arrive in order, one call at a time. The server validates each event against the capability's schema before your callback sees it.
  • Your callback sets the pace. Credit goes back to the device when the callback returns, or when its promise settles. A slow consumer therefore slows the device down instead of filling server memory. For mic.record, the device holds a bounded buffer while it waits. If that buffer fills, the recording ends with throttled.
  • If the callback throws, the chunk still counts as consumed.
  • settled resolves last, after every accepted chunk or event has been delivered. For mic.record it carries { durationMs, item }. The server verified the item's bytes and sha256 against everything delivered, and a mismatch settles as invalidParams.
  • cancel() (or aborting init.signal) tells the device to stop, and settled resolves { ok: false, error: { code: "cancelled" } }.

Passing the wrong kind of consumer (an onEvent function for mic.record, or { onData } for bluetooth.scan) fails locally with invalidParams.

type Nearby = { id: string; name: string; rssi: number };
const scans = new WeakMap<object, { cancel(): void }>(); // per module instance

app
  .defineState({ devices: [] as Nearby[], scanning: false, error: "" })
  .onAction("startScan", ({ state, context }) => {
    state.devices = [];
    const handle = context.device.stream("bluetooth.scan", {}, { timeoutMs: 120_000 }, (ev) => {
      const i = state.devices.findIndex((d) => d.id === ev.device.id);
      const entry = { id: ev.device.id, name: ev.device.name ?? "Unknown", rssi: ev.device.rssi };
      if (i >= 0) state.devices[i] = entry;
      else state.devices.push(entry);
    });
    scans.set(state, handle);
    void handle.settled.then((r) => {
      state.scanning = false; // don't leave state saying the scan is live
      if (!r.ok && r.error.code !== "cancelled") state.error = r.error.code;
    });
    state.scanning = handle.id !== null;
  })
  .onAction("stopScan", ({ state }) => scans.get(state)?.cancel());

Unlike a unary request, a stream keeps running after the handler that opened it returns. It ends when it settles, when you cancel it, or when its module deactivates.

Other SDKs

The concepts carry over. Each SDK expresses them in its own idiom:

ConceptGoKotlinSwiftRust
Result(value, error). err is a *device.Error with a Code. errors.Is(err, device.ErrDenied) works.DeviceResult.Ok(value, simulated) / DeviceResult.Err(DeviceFailure(code, platformDetail))Result<DeviceValue<T>, DeviceError>. DeviceValue exposes .value and .simulated.DeviceResult<Delivered<T>> = Result<Delivered<T>, DeviceError>; Delivered derefs to the value and has .simulated; DeviceError { code, detail }
UnaryDevice().Request(ctx, name, params, opts...), typed helpers (Gallery().Pick, …), device.RequestAs[T]device.request(Capability.GALLERY_PICK, params, options) and the wrappersawait device.request(GalleryPick(...)) and the wrappersdevice.request(name, json) / request_with(.., RequestOptions) and typed helpers (gallery_pick, file_pick, …) return Err (refused locally) or a DeviceCall consumed with then, on_settled or wait
SaveDevice().Save(ctx, name, contentType, data)device.save(bytes, name, contentType)await device.save(data, name:contentType:)device.save(name, content_type, bytes) → DeviceCall<SaveReceipt>
StreamDevice().Stream(...), then Next(ctx) until io.EOF, then Result(ctx)device.stream(cap, params) { … }.await(), or a cold Flow via events(...) / data(...)DeviceEventStream / DeviceDataStream are AsyncSequences; await stream.result()device.stream(..) → DeviceStream, consumed with for_each::<S>(|state, item| ..) or on_item; items are Event, Data then one End
CancelCancel the context.Context, or Stream.Cancel(). Return device.ErrStop from a callback to end a stream.Cancel the coroutine (or withTimeout), or DeviceStream.cancel(). Stopping a Flow early cancels the stream.Cancel the Task, or stream.cancel()call.cancel(), a DeviceHandle (call.handle(), returned by for_each) .cancel(); dropping an unconsumed call cancels it
Optionsdevice.WithTimeout(d), device.WithInitialCredit(n), device.WithLifetime(l)DeviceRequestOptions(lifetime, timeoutMs, initialCredit)DeviceRequestOptions (lifetime, timeoutMs, initialCredit)RequestOptions { version, lifetime, timeout, initial_credit }
SupportsDevice().Supports(name)device.supports(name) or supports(Capability.X)device.supports(name) or supports(GalleryPick.self)device.supports(name), selected_version(name), capabilities()

In Go, a device call blocks the goroutine. While a handler waits, the session's dispatch slot is released, so the session's other actions keep running.

OnAction("record", func(ctx core.TypedActionContext[State]) {
    var pcm bytes.Buffer
    res, err := ctx.Device().Mic().Record(context.Background(),
        device.MicRecordParams{SampleRate: 16000},
        func(chunk []byte) error { pcm.Write(chunk); return nil })
    if err != nil {
        ctx.State.Error = string(device.CodeOf(err))
        return
    }
    ctx.State.Seconds = int(res.DurationMs / 1000)
})
.onActionAsync("scan") { ctx ->
    try {
        val first = ctx.device.events(Capability.BLUETOOTH_SCAN, BluetoothScanParams).take(5).toList()
        ctx.state.set("devices", first.map { it.device.id })
    } catch (e: DeviceException) {
        ctx.state.set("error", e.failure.code.wireName)
    }
}

In Rust, handlers are synchronous and run with the session locked, so a call never blocks one: the result arrives later, and then applies it to the module's state (its patches ship like an action's).

.on_action::<()>("record", |state, _, ctx| {
    let params = MicRecordParams { sample_rate: 16_000, format: MicFormat::Pcm16,
                                   max_duration_ms: Some(30_000), channels: None };
    match ctx.expect("remote handler").device().mic_record(params) {
        Ok(stream) => {
            stream.for_each(|s: &mut State, item| match item {
                StreamItem::Data { bytes, .. } => s.pcm_bytes += bytes.len(),
                StreamItem::End(Ok(r)) => s.duration_ms = r["durationMs"].as_u64().unwrap_or(0),
                StreamItem::End(Err(e)) => s.error = e.code_str().into(),
                StreamItem::Event(_) => {}
            });
        }
        Err(e) => state.error = e.code_str().into(),
    }
})
.onActionAsync("record") { ctx in
    let recording = ctx.device.mic.record(sampleRate: 16000, maxDurationMs: 30_000)
    var pcm = Data()
    for await chunk in recording { pcm.append(chunk.bytes) }
    switch await recording.result() {
    case .success(let r): ctx.state.set("durationMs", Int(r.durationMs))
    case .failure(let e): ctx.state.set("error", e.code.rawValue)
    }
}