Device SDK reference
bext.device.*, bext.notifications.*, and bext.app.* reach on-device native
capabilities from your JS: camera, Face ID, GPS, torch, keychain, share sheet,
local notifications, and inbound events. They come from @bext-stack/platform,
the same client you use for KV and queues.
import { createClient } from "@bext-stack/platform";
const bext = createClient();
const shot = await bext.device.camera.capture({ quality: 80 });
if (shot.ok) console.log(shot.body.uri);
These methods only do anything inside a bext-lite mobile shell. There, the
fetch("http://127.0.0.1/__bext/sdk/device/...") under the hood is intercepted by
the runtime and forwarded to the native bridge the shell installs. On desktop,
edge, or the masquerade proxy there is no bridge, so every call resolves
ok: false with status 501. See /docs/mobile for the full story.
The envelope: branch on ok, never catch#
Unlike the KV and queue clients (which throw on a non-2xx), device calls surface
the raw outcome as a DeviceEnvelope and never throw:
interface DeviceEnvelope<T> {
ok: boolean; // Response.ok: true only for a 2xx native response
status: number; // 200 success, 500 native error, 501 no bridge, 0 network/timeout
body: T; // typed payload on success; { error } on failure; null if empty
}
Status values you will see:
| status | meaning |
|---|---|
200 |
success, body is the typed payload |
500 |
a native failure, body is { error } |
501 |
no bridge installed (desktop / edge / masquerade) - expected off-device |
404 / 401 |
a non-mobile origin (the masquerade routes it elsewhere) |
0 |
the call never reached the bridge (network error or timeout) |
So the correct shape is always a branch on ok, not a try/catch:
const bio = await bext.device.biometrics.isAvailable();
if (bio.ok && bio.body.available) {
const auth = await bext.device.biometrics.authenticate({ reason: "Unlock" });
if (auth.ok && auth.body.verified) unlock();
} else {
// off-device, or no biometric hardware: fall back to a passcode
showPasscodePrompt();
}
The marquee degradation check is one line:
(await bext.device.biometrics.isAvailable()).body?.available === true. It is
false off-device and false on a device with no enrolled biometric, and it
never throws either way.
Device calls front interactive OS prompts (camera, Face ID, the share sheet) that
legitimately outlast the 30 s client default, so they get a 120 s deadline. A
timeout still resolves to an { ok: false, status: 0 } envelope rather than
throwing, so the deadline only bounds a truly wedged bridge.
bext.device.camera#
capture(options?: CameraCaptureOptions): Promise<DeviceEnvelope<{ uri: string }>>;
pickImage(): Promise<DeviceEnvelope<{ uri: string }>>;
pickVideo(): Promise<DeviceEnvelope<{ uri: string }>>;
CameraCaptureOptions: mode?: "photo" | "video" (default "photo"), facing?: "front" | "back" (default "back"), quality?: number (JPEG quality 0 to 100).
uri points at the on-device file the WebView can load or upload.
const shot = await bext.device.camera.capture({ facing: "front", quality: 90 });
if (shot.ok) avatar.src = shot.body.uri;
const pick = await bext.device.camera.pickImage();
if (pick.ok) upload(pick.body.uri);
bext.device.biometrics#
isAvailable(): Promise<DeviceEnvelope<{ available: boolean }>>;
authenticate(options?: { reason?: string }): Promise<DeviceEnvelope<{ verified: boolean }>>;
Face ID, Touch ID, or fingerprint. reason is shown in the OS prompt.
const r = await bext.device.biometrics.authenticate({ reason: "Confirm payment" });
if (r.ok && r.body.verified) confirmPayment();
bext.device.geolocation#
current(options?: { highAccuracy?: boolean })
: Promise<DeviceEnvelope<{ lat: number; lng: number; accuracy?: number }>>;
watch(options?: { highAccuracy?: boolean })
: Promise<DeviceEnvelope<{ watchId: string }>>;
clearWatch(watchId: string): Promise<DeviceEnvelope<{ ok: true }>>;
current returns a one-shot position (with accuracy in metres when known).
watch starts a continuous watch and returns a watchId stop handle; the
position updates themselves arrive as inbound events (see below), not as the
return value.
const w = await bext.device.geolocation.watch({ highAccuracy: true });
if (w.ok) {
const watchId = w.body.watchId;
// updates arrive via device.events as { type: "geolocation.update", watchId, lat, lng }
// when done:
await bext.device.geolocation.clearWatch(watchId);
}
bext.device.haptics#
impact(style?: "light" | "medium" | "heavy"): Promise<DeviceEnvelope<{ ok: true }>>;
notification(type?: "success" | "warning" | "error"): Promise<DeviceEnvelope<{ ok: true }>>;
selection(): Promise<DeviceEnvelope<{ ok: true }>>;
Fire-and-forget haptic feedback. impact defaults to "medium", notification
to "success".
await bext.device.haptics.impact("light");
await bext.device.haptics.notification("success");
bext.device.flashlight#
on(): Promise<DeviceEnvelope<{ on: boolean }>>;
off(): Promise<DeviceEnvelope<{ on: boolean }>>;
Toggles the camera torch; on reflects the resulting state.
const t = await bext.device.flashlight.on();
if (t.ok && t.body.on) showTorchIsOn();
bext.device.secureStore#
get(key: string): Promise<DeviceEnvelope<{ value: string | null }>>;
set(key: string, value: string): Promise<DeviceEnvelope<{ ok: true }>>;
delete(key: string): Promise<DeviceEnvelope<{ ok: true }>>;
OS keychain / keystore-backed storage. get on a missing key returns
{ value: null }.
await bext.device.secureStore.set("session-token", token);
const s = await bext.device.secureStore.get("session-token");
if (s.ok && s.body.value) resume(s.body.value);
bext.device.share#
share(options: ShareOptions): Promise<DeviceEnvelope<{ shared: boolean }>>;
ShareOptions: title?: string, text?: string, url?: string. Opens the OS
share sheet; shared reflects whether a share completed.
const r = await bext.device.share({ title: "Look", url: "https://example.com" });
if (r.ok && r.body.shared) toast("Shared");
bext.notifications#
request(): Promise<DeviceEnvelope<{ granted: boolean }>>;
schedule(notification: NotificationSchedule): Promise<DeviceEnvelope<{ id: string }>>;
cancel(id: string): Promise<DeviceEnvelope<{ ok: true }>>;
Local notification permission and scheduling. NotificationSchedule: title: string, body?: string, inSeconds?: number (fire after N seconds) or at?: number (absolute epoch millis), data?: Record<string, unknown> (delivered back
to the app on tap). schedule returns an id you can cancel.
const perm = await bext.notifications.request();
if (perm.ok && perm.body.granted) {
const n = await bext.notifications.schedule({
title: "Break time",
body: "Stand up and stretch.",
inSeconds: 1500,
data: { kind: "break" },
});
if (n.ok) rememberNotificationId(n.body.id);
}
A notification tap comes back as an inbound event (see below).
bext.app#
version(): Promise<DeviceEnvelope<{ version: string }>>;
platform(): Promise<DeviceEnvelope<{ platform: string }>>;
openSettings(): Promise<DeviceEnvelope<{ ok: true }>>;
App and runtime info plus OS integration. platform is e.g. "ios" or
"android"; openSettings opens the OS settings screen for this app.
const p = await bext.app.platform();
if (p.ok) console.log("running on", p.body.platform);
Inbound events#
Device-to-app events (notification taps, deep-link opens, geolocation-watch
updates, app resumes) have no awaiting fetch to answer, so the native shell
pushes them onto an in-Rust ring and the client drains it.
interface DeviceEvents {
drain(max?: number): Promise<DeviceEnvelope<{ events: NativeEvent[] }>>;
subscribe(
handler: (event: NativeEvent) => void,
opts?: { intervalMs?: number },
): { stop(): void };
}
interface NativeEvent {
type: string; // the discriminator you dispatch on
[key: string]: unknown;
}
drain(max?) removes and returns up to max buffered events (default 64).
subscribe(handler, opts?) polls drain every intervalMs (default 1000) and
invokes handler per event; it returns a stop() to end the poll.
const sub = bext.device.events.subscribe((event) => {
switch (event.type) {
case "deeplink": router.navigate(event.url as string); break;
case "notification.tap": openFromNotification(event.data); break;
case "geolocation.update": updateMarker(event.watchId, event.lat, event.lng); break;
case "app.resume": revalidate(); break;
}
}, { intervalMs: 500 });
// when the view unmounts:
sub.stop();
Event shapes pushed by the shell:
| type | fields |
|---|---|
deeplink |
url |
notification.tap |
id, data |
geolocation.update |
watchId, lat, lng |
app.resume |
(none) |
Subscription-shaped device ops are producers into this ring:
geolocation.watch emits geolocation.update events keyed by its watchId, and
clearWatch stops them. That is why watch returns only a handle and the
positions arrive here.
The security boundary#
Only the device/ namespace is reachable over HTTP. The data SDK (kv, db, fs,
queue, cache, secrets) is in-process-only: reachable during SSR and API execution,
never over the loopback socket.
This is a deliberate boundary because bext-lite also runs as public-proxied
untrusted-hosting tenants. A direct POST /__bext/sdk/kv/get from a client returns
404 by design, so a tenant's data cannot leak. For client-side data, call your
app's own /api/* routes, which touch the SDK server-side:
browser --> /api/notes (your app route, HTTP-reachable)
/api/notes --> /__bext/sdk/kv/* (in-process, SQLite on device)
The inbound-event drain lives under device/events/drain, so it sits inside this
same boundary and never opens the data SDK over HTTP.
See /docs/mobile for the bridge architecture and /docs/mobile-cli for building the shell.