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.
| SDK | Where the device context comes from |
|---|---|
| TypeScript | context.device in action handlers, and context?.device in lifecycle handlers |
| Go | ctx.Device() (never nil) |
| Kotlin | ctx.device in onActionAsync, and context.device in lifecycle handlers |
| Swift | ctx.device in onActionAsync, and the device argument of onActivatedAsync |
| Rust | ctx.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"); // booleansupports() 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:
| Wrapper | Same 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.codeis one of the ten error codes.platformDetailis 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: truemeans 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:
| Option | Meaning |
|---|---|
timeoutMs | Overall deadline. Defaults to 300 s, clamped to the capability's maximum. |
signal | An AbortSignal. Aborting it cancels the request, which settles as cancelled. If the signal is already aborted, nothing is sent. |
initialCredit | Initial 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> }): DeviceStreamHandleA 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 withthrottled. - If the callback throws, the chunk still counts as consumed.
settledresolves last, after every accepted chunk or event has been delivered. Formic.recordit carries{ durationMs, item }. The server verified the item'sbytesandsha256against everything delivered, and a mismatch settles asinvalidParams.cancel()(or abortinginit.signal) tells the device to stop, andsettledresolves{ 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:
| Concept | Go | Kotlin | Swift | Rust |
|---|---|---|---|---|
| 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 } |
| Unary | Device().Request(ctx, name, params, opts...), typed helpers (Gallery().Pick, …), device.RequestAs[T] | device.request(Capability.GALLERY_PICK, params, options) and the wrappers | await device.request(GalleryPick(...)) and the wrappers | device.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 |
| Save | Device().Save(ctx, name, contentType, data) | device.save(bytes, name, contentType) | await device.save(data, name:contentType:) | device.save(name, content_type, bytes) → DeviceCall<SaveReceipt> |
| Stream | Device().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 |
| Cancel | Cancel 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 |
| Options | device.WithTimeout(d), device.WithInitialCredit(n), device.WithLifetime(l) | DeviceRequestOptions(lifetime, timeoutMs, initialCredit) | DeviceRequestOptions (lifetime, timeoutMs, initialCredit) | RequestOptions { version, lifetime, timeout, initial_credit } |
| Supports | Device().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)
}
}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
Errors
The ten device error codes, when each occurs, the platformDetail values you will see, and how to handle errors in each SDK