One API, two implementations. On phones it wraps the Expo modules you install. In the simulator it is a simulated device: the system dialogs are drawn as real dialogs in the accessibility tree, and the agent (or test) sets what the phone has. The app code is identical.
import { device } from "@shipvo/device";Support levels
device.kind // "native" | "simulated"
device.capabilities() // { camera: "real" | "simulated" | "unavailable", location: …, biometrics, payments, notifications, share, clipboard, network }Every capability declares how it is provided here. There is no silent fallback: on a phone without expo-camera installed, camera is unavailable and scan() throws; in the simulator every capability is simulated. Tests and agents read this to know what was actually exercised. Simulated results are also marked in what they return: receipts start with SIMULATED- (simulator) or SANDBOX- (the built-in sandbox provider on phones until a gateway is configured), and every entry in the simulator's device log carries simulated: true.
Permissions
await device.permissions.get("camera" | "location" | "notifications") // "granted" | "denied" | "undetermined"
await device.permissions.request("camera" | "location" | "notifications")On phones: the Expo permission calls. In the simulator: the platform's own dialog, worded as Android ("Allow My Shop to take pictures and record video?" with While using the app / Only this time / Deny) or iOS ("“My Shop” Would Like to Access the Camera" with Allow / Don't Allow) words it, depending on the simulated device's platform. Android below API 33 grants notifications without asking, as the OS does. The answer is remembered for the session.
Location
const c = await device.location.current(); // { latitude, longitude, accuracy? }Asks for foreground permission first (so the system dialog appears on the first use, on phones and in the simulator), then reads one position. Throws Location permission denied when refused. Simulator default: 4.0225, 9.6967 (Douala); set with set_device.
Camera: scanning codes
const r = await device.camera.scan({ prompt: "Point at the barcode" }); // { type, data } | null (cancelled)On phones the scanner is a screen; mount <ScanHost accent="#1F4E5F" /> from @shipvo/device/scan once at your app root and scan() shows it (camera preview with EAN-13/EAN-8/QR/Code-128 decoding, a manual-entry field for scratched packs and weak cameras, cancel). Apps with their own scanner screen can skip ScanHost and use expo-camera directly; the simulator swaps expo-camera for its simulated one through the Metro alias.
In the simulator the camera "sees" whatever set_device put in front of it: camera: { barcode: { type: "qr", data: "6181234567890" }, delayMs: 800 }. With no barcode set, the viewfinder stays open until cancelled.
Biometrics
const ok = await device.auth.available(); // hardware present and enrolled
const r = await device.auth.confirm("Confirm payment of 4 200 FCFA");
// { ok: true } | { ok: false, reason: "cancelled" | "failed" | "unavailable" | "not_enrolled" }Phones: expo-local-authentication (fingerprint, Face ID, device PIN fallback). Simulator: a fingerprint (Android) or Face ID (iOS) prompt in the tree; set_device chooses biometrics: { enrolled, nextResult: "success" | "fail" | "cancel" }. Nothing is verified; the log says so.
Payments
const r = await device.pay.charge({ provider: "mobile_money" | "card", amount, currency, to, reference, phone? }, pin);
// { ok: true, receipt } | { ok: false, reason: "declined" | "cancelled" | "timeout" | "insufficient_funds" }The amount/PIN prompt is your app's own sheet, identical on every platform (Tide & Tackle's PaymentSheet is a reference). The provider decides the outcome. Today the provider on phones is a sandbox (PIN 1234 approves, receipt SANDBOX-…); the simulator approves, declines, times out or reports insufficient funds as set_device says (payment: { nextResult, pin }), receipt SIMULATED-…. No money moves anywhere yet; a real mobile-money or card gateway plugs in behind this interface.
Notifications
const id = await device.notifications.show({ title, body, data? });
const off = device.notifications.onOpen((n) => { /* n.data */ });Phones: a local notification through expo-notifications; tapping it fires onOpen. Simulator: a heads-up banner over the status bar for 5 seconds (as on a phone), then it lives in the notification shade: agents list it with the notifications MCP tool and tap it with open_notification, which fires onOpen exactly as a banner tap would.
Share and clipboard
await device.share.text("Order TT-1001: …", "My order"); // true if shared
await device.clipboard.set(text); await device.clipboard.get();Simulator: a share sheet with WhatsApp / Messages / Copy; Copy writes the simulated clipboard.
Network
device.net.online(); // boolean
device.net.onChange((online) => …);Simulator: set_device(online: false) takes the phone offline; the app's offline banner and queued actions can be tested without touching a radio.
The simulated device from the agent's side
| MCP tool | Effect |
|---|---|
set_device(session, platform?, api?, permissions?, location?, camera?, biometrics?, payment?, online?) | Change any part of the device state mid-session |
device_reset(session, profile?) | Back to a known state: permissions undetermined, default location, nothing in view, log cleared |
push_notification(session, title, body, data?) | Deliver a notification to the phone |
notifications(session) / open_notification(session, id?) | The shade; tap one |
device_log(session) | Every device event in the session (permission prompts and answers, location reads, scans, auth, payments, notifications, shares), each simulated: true, plus capabilities() |
Using raw Expo modules
Apps that already import expo-camera, expo-location, expo-local-authentication or expo-notifications directly also run in the simulator: withShipvoSim in metro.config.js aliases them to shims that call the simulated device when the bundle is built with PN_SIM=1. Phone builds are untouched.