@ateam-ai-mobile/plugin-runtime 0.1.5 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sdk/types.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * This is the developer contract for native mobile plugins.
5
5
  * Both UI and service plugins use these interfaces.
6
6
  */
7
+ import type { DeviceState, DeviceBridgeDomain } from './device-state';
8
+ import type { VoiceAPI } from './voice';
7
9
 
8
10
  // ── Plugin Context ──────────────────────────────────────────
9
11
 
@@ -55,8 +57,27 @@ export interface PluginBridge {
55
57
 
56
58
  export interface CameraAPI {
57
59
  takePhoto(opts?: { quality?: number; facing?: 'front' | 'back' }): Promise<{ uri: string; width: number; height: number }>;
60
+ /**
61
+ * @deprecated Use `native.scanner.scanOnce()`. Scanning needs a MOUNTED
62
+ * camera, which a bare function call cannot provide; this refuses by name
63
+ * and points there.
64
+ */
58
65
  scanBarcode(opts?: { formats?: string[] }): Promise<{ data: string; format: string }>;
59
66
  pickImage(opts?: { multiple?: boolean }): Promise<{ uri: string }[]>;
67
+ /**
68
+ * Record a video clip the user deliberately makes — a damage claim, a
69
+ * how-it-sounds clip, something to attach.
70
+ *
71
+ * NOT the Visual domain. That is an ephemeral frame ring a reasoning loop
72
+ * reads and nobody keeps; this produces a FILE. Different jobs, and the
73
+ * grammar says so: this is a verb that throws, Visual is a state read that
74
+ * says `unknown`.
75
+ *
76
+ * Rides the same mounted camera surface as `scanner.scanOnce`, and throws
77
+ * the same coded failures. `withAudio` needs microphone permission and
78
+ * REFUSES without it rather than quietly recording silence.
79
+ */
80
+ recordVideo(opts?: { maxDurationMs?: number; withAudio?: boolean }): Promise<{ uri: string; durationMs: number | null }>;
60
81
  }
61
82
 
62
83
  export interface LocationAPI {
@@ -88,6 +109,16 @@ export interface HapticsAPI {
88
109
  impact(style: 'light' | 'medium' | 'heavy'): void;
89
110
  notification(type: 'success' | 'warning' | 'error'): void;
90
111
  selection(): void;
112
+ /**
113
+ * Shorthand for notification('error').
114
+ *
115
+ * Documented in the platform guide and already CALLED by shipped plugins
116
+ * (connections-panel), which only compiled because they reached it through
117
+ * optional chaining — `native?.haptics?.error?.()` silently does nothing when
118
+ * the method is absent, so a failure the user was meant to FEEL produced no
119
+ * buzz and no error. Declared here so the call site is checked.
120
+ */
121
+ error(): void;
91
122
  }
92
123
 
93
124
  export interface ConnectivityAPI {
@@ -139,11 +170,22 @@ export interface ScannerAPI {
139
170
  /** Prompt the user for camera permission. */
140
171
  requestPermissions(): Promise<{ granted: boolean; status: string }>;
141
172
  /**
142
- * Scanner is camera-based; the plugin mounts the scanner component
143
- * directly from device-bridge. This API exposes only the permission
144
- * wrapper at the plugin-sdk level — the actual scan is a React
145
- * component lifecycle.
173
+ * Show the scanner and resolve with the FIRST code read.
174
+ *
175
+ * The plugin no longer mounts anything: the host renders one
176
+ * `CameraTaskHost`, this raises it, and the promise settles when a code is
177
+ * found. That is why the capture lifecycle is owned in exactly one place
178
+ * instead of by every plugin that wants to scan.
179
+ *
180
+ * THROWS rather than resolving empty, with a code that says which:
181
+ * TIMEOUT nothing was found in time
182
+ * CANCELLED the user dismissed it — a choice, not a fault
183
+ * PERMISSION_DENIED camera permission refused
184
+ * BUSY another camera task is running
185
+ * UNAVAILABLE no camera surface is mounted in this app
186
+ * An empty string would read as a code that scanned to nothing.
146
187
  */
188
+ scanOnce(opts?: { formats?: string[]; timeoutMs?: number }): Promise<{ data: string; format: string }>;
147
189
  }
148
190
 
149
191
  // ── NFC API (tag read) ────────────────────────────────────
@@ -171,10 +213,304 @@ export interface BiometricsAPIv2 {
171
213
  }
172
214
 
173
215
 
216
+ // ── Bluetooth / BLE ───────────────────────────────────────
217
+ //
218
+ // SHAPE ONLY — every method throws CapabilityUnavailableError in this build.
219
+ // It is declared anyway, and that is the point: `bluetooth` was already a
220
+ // manifest flag with nothing behind it, so a plugin could declare it, pass the
221
+ // gate, and reach `undefined`. Calling `native.bluetooth.scan()` then failed
222
+ // with "cannot read properties of undefined" — a crash that tells the author
223
+ // nothing and a model less than nothing.
224
+ //
225
+ // A declared-but-unbuilt capability must FAIL BY NAME. That is the whole rule:
226
+ // absence is an answer, but only when it says what is absent.
227
+ //
228
+ // WHY IT IS KEPT RATHER THAN DROPPED: BLE beacons are one of the few indoor
229
+ // position signals that work where GPS does not, which is exactly the case Ada
230
+ // Guide runs into the moment the user walks into a building. Also pairing,
231
+ // loyalty tags, and proximity triggers. The shape is here so a solution can be
232
+ // designed against it before the native side exists.
233
+ //
234
+ // IMPLEMENTING IT is a real project, not a wiring job: react-native-ble-plx,
235
+ // iOS background modes, NSBluetoothAlwaysUsageDescription, and a permission
236
+ // flow of its own.
237
+ export interface BluetoothDevice {
238
+ id: string;
239
+ name: string | null;
240
+ /** Signal strength in dBm. Null when not reported — never 0, which is a
241
+ * legitimate (and very strong) reading. */
242
+ rssi: number | null;
243
+ /** Manufacturer/service payload, if the advertisement carried one. */
244
+ serviceUUIDs?: string[];
245
+ }
246
+
247
+ // ── Vision — on-device image understanding ────────────────
248
+ //
249
+ // SHAPE ONLY. Every method refuses by name until a native module lands.
250
+ //
251
+ // WHY IT IS HERE AND NOT IN THE VISUAL DOMAIN. The freeze is explicit
252
+ // (device-state.ts §3): "Visual is NOT the camera API… Everything interpretive
253
+ // — what is in the scene, what the sign says, which corridor to take — sits
254
+ // ABOVE Device Bridge and is not modelled here." Visual owns the frame
255
+ // TIMELINE; deciding what a frame MEANS is this layer's job. Two different
256
+ // questions, deliberately kept apart:
257
+ //
258
+ // deviceState.visual.getSnapshot() → "is there a current frame, how old"
259
+ // native.vision.readText(frame) → "what does the sign say"
260
+ //
261
+ // FRAMES NEVER LEAVE THE PHONE. That is Arie's decision (2026-08-15) and it is
262
+ // the whole reason this is a platform capability rather than an API call from
263
+ // a plugin: analysis runs on-device, so there is no per-frame cost and no image
264
+ // on anyone's server. An implementation that POSTs the frame somewhere would
265
+ // satisfy the types and violate the design.
266
+ //
267
+ // IT TAKES A HANDLE, NOT PIXELS. `MediaHandle.id` from a VisualFrameRef — the
268
+ // core rule that media crosses by reference. A plugin never holds bytes.
269
+ //
270
+ // NATIVE SIDE (for whoever implements it): iOS Vision —
271
+ // VNRecognizeTextRequest for `readText`, VNClassifyImageRequest for `describe`;
272
+ // Android MLKit text recognition + image labeling. Both are on-device and free
273
+ // per call.
274
+ export interface VisionText {
275
+ /** The recognised string. */
276
+ text: string;
277
+ /** 0..1 recogniser confidence. Null when the platform gives no estimate —
278
+ * never 1, which would read as certainty nothing measured. */
279
+ confidence: number | null;
280
+ /** Normalised 0..1 box in the frame, origin top-left. Lets a guide say
281
+ * "the sign on your right" rather than just quoting words. */
282
+ box?: { x: number; y: number; width: number; height: number };
283
+ }
284
+
285
+ export interface VisionLabel {
286
+ /** A label from the platform's classifier vocabulary — NOT free text. */
287
+ label: string;
288
+ confidence: number | null;
289
+ }
290
+
291
+ export interface VisionObject {
292
+ /** A detector-vocabulary class name (e.g. "Car", "Traffic light", "Person") —
293
+ * NOT free text. */
294
+ label: string;
295
+ /** 0..1 detector confidence. Null when the platform gives no estimate — never
296
+ * 1, which would read as certainty nothing measured. */
297
+ confidence: number | null;
298
+ /** Normalised 0..1 box in the frame, origin top-left. Where the thing is,
299
+ * so a guide can say "on your right" rather than just naming it. */
300
+ box?: { x: number; y: number; width: number; height: number };
301
+ }
302
+
303
+ /**
304
+ * How usable a frame is, as measured rather than guessed.
305
+ *
306
+ * This one is the exception to the split above: it is a MEASUREMENT of the
307
+ * image, not an interpretation of the scene, so its results belong on
308
+ * `VisualFrameRef.quality` — which currently reports `unknown` for all three
309
+ * because nothing looks at the pixels. Wiring this is what lets a guide say
310
+ * "it's too dark for me to tell" instead of describing a black frame.
311
+ */
312
+ export interface VisionFrameQuality {
313
+ blur: 'sharp' | 'slight' | 'severe' | 'unknown';
314
+ exposure: 'good' | 'dark' | 'bright' | 'unknown';
315
+ /** True when the lens is covered. Null = not assessed. */
316
+ obstructed: boolean | null;
317
+ }
318
+
319
+ export interface VisionAPI {
320
+ /** Is on-device vision available at all? False is a real answer. */
321
+ isSupported(): Promise<boolean>;
322
+ /**
323
+ * Read text in a frame (OCR).
324
+ *
325
+ * Returns an EMPTY ARRAY when it looked and found no text — that is a real
326
+ * finding, distinct from throwing, which means it could not look.
327
+ */
328
+ readText(frameId: string, opts?: { languages?: string[] }): Promise<VisionText[]>;
329
+ /**
330
+ * Label what is in a frame.
331
+ *
332
+ * Deliberately labels, not prose. A sentence is the SKILL's job — this layer
333
+ * must not put words in the model's mouth, and a classifier that returns
334
+ * "a bathroom sign ahead on the left" is asserting a spatial relationship it
335
+ * did not measure.
336
+ */
337
+ describe(frameId: string, opts?: { maxLabels?: number }): Promise<VisionLabel[]>;
338
+ /**
339
+ * Detect the objects in a frame, each with a box.
340
+ *
341
+ * Distinct from `describe`: describe LABELS the whole scene ("kitchen"),
342
+ * detectObjects LOCATES the things in it ("Person" on the left, "Car" ahead) —
343
+ * which is what a guidance loop reads to say where something is. Returns an
344
+ * EMPTY ARRAY when it looked and found nothing, distinct from throwing (could
345
+ * not look). On-device (bundled detector); frames never leave the phone.
346
+ */
347
+ detectObjects(
348
+ frameId: string,
349
+ opts?: { maxItems?: number; minConfidence?: number },
350
+ ): Promise<VisionObject[]>;
351
+ /** Measure whether a frame is usable. Feeds VisualFrameRef.quality. */
352
+ assessQuality(frameId: string): Promise<VisionFrameQuality>;
353
+ }
354
+
355
+ // ── Maps — where is X, what is here, how do I walk there ──
356
+ //
357
+ // One-shot verbs that THROW, like vision — not a state domain. Following a
358
+ // route over time (progress, off-route, "turn left in 20 m") is the reasoning
359
+ // loop's job over deviceState.spatial; this layer answers each question once.
360
+ //
361
+ // NOT ON-DEVICE — the opposite of vision, and the thing not to get wrong when
362
+ // describing a feature built on it. Every call sends the query and the
363
+ // coordinates you pass to a map service over the network: Apple's (MapKit /
364
+ // CLGeocoder) on iOS, the platform geocoder on Android. So the `maps` flag
365
+ // means exactly one thing: "this plugin may send place queries to Apple Maps".
366
+ //
367
+ // IT NEVER READS THE USER'S LOCATION. Every coordinate is the caller's — take a
368
+ // fix from `location.getCurrent` or `deviceState.spatial` and pass it in. That
369
+ // keeps `maps` from becoming a second, differently-gated door to where the
370
+ // user is.
371
+ //
372
+ // FOUND NOTHING IS AN ANSWER; COULD NOT LOOK THROWS. `[]` / `null` mean the
373
+ // service looked and there is nothing. Anything else THROWS, with a code:
374
+ // UNAVAILABLE this app build has no AdaMaps module (needs a native rebuild),
375
+ // or the platform has none — search/route are iOS only (MapKit)
376
+ // and refuse by name on Android. Also the service's own failures.
377
+ // OFFLINE the map service could not be reached.
378
+ // THROTTLED Apple is rate-limiting this app. A burst of queries is what
379
+ // trips it — search once per intent, not once per frame.
380
+ // And one refusal that is not a failure to look:
381
+ // INVALID_ARGUMENT the call itself is malformed — an empty query, a
382
+ // coordinate that is not { latitude, longitude } finite numbers in
383
+ // range (`{ lat, lng }` refuses), a limit or radius out of range.
384
+ // Nothing was sent; fix the call rather than retrying it.
385
+
386
+ /** The same `{ latitude, longitude }` `location.getCurrent` returns, so a fix
387
+ * passes straight in. */
388
+ export interface MapsCoordinate {
389
+ latitude: number;
390
+ longitude: number;
391
+ }
392
+
393
+ /** A postal address, field by field. `null` = the map data has no value — never
394
+ * an empty string, which would read as a street with no name. */
395
+ export interface MapsAddress {
396
+ /** The placemark's own name — a building, or often number + street. */
397
+ name: string | null;
398
+ street: string | null;
399
+ streetNumber: string | null;
400
+ /** Neighbourhood / sub-locality. */
401
+ district: string | null;
402
+ city: string | null;
403
+ /** State / province / administrative area. */
404
+ region: string | null;
405
+ postalCode: string | null;
406
+ country: string | null;
407
+ /** ISO 3166-1 alpha-2, e.g. "GB". */
408
+ isoCountryCode: string | null;
409
+ }
410
+
411
+ export interface MapsPlace {
412
+ /** What the map calls it ("Boots", "10 Downing St"). Null when unnamed. */
413
+ name: string | null;
414
+ coordinate: MapsCoordinate;
415
+ address: MapsAddress;
416
+ /** Apple's point-of-interest category as a vocabulary term ("Pharmacy",
417
+ * "Cafe", "PublicTransport") — NOT free text. Null for a plain address, or
418
+ * when the service gives none. */
419
+ category: string | null;
420
+ /** Straight-line metres from `opts.near`, when you passed one — NOT walking
421
+ * distance (ask `route` for that). Null without `near`. */
422
+ distanceM: number | null;
423
+ }
424
+
425
+ export interface MapsRouteStep {
426
+ /** Apple's instruction ("Turn right onto Baker St"). The FIRST step is usually
427
+ * an origin step with an empty instruction — kept, not dropped, so step
428
+ * indexes line up with Apple's. */
429
+ instructions: string;
430
+ distanceM: number;
431
+ /** An advisory Apple attaches to this step. Null when there is none. */
432
+ notice: string | null;
433
+ polyline: MapsCoordinate[];
434
+ }
435
+
436
+ export interface MapsRoute {
437
+ mode: 'walking';
438
+ distanceM: number;
439
+ /** Apple's estimate for the whole route. */
440
+ expectedTravelTimeMs: number;
441
+ steps: MapsRouteStep[];
442
+ /** The whole route, start to end. */
443
+ polyline: MapsCoordinate[];
444
+ }
445
+
446
+ export interface MapsAPI {
447
+ /** Can this build search and route? True when the AdaMaps module is in the
448
+ * binary (iOS). False on Android and on an older build — `reverseGeocode`
449
+ * does not need the module and may still work there. */
450
+ isSupported(): Promise<boolean>;
451
+ /**
452
+ * Find places by free text — "pharmacy", "10 Downing Street".
453
+ *
454
+ * `near` centres the search on a coordinate YOU supply, within `radiusM`
455
+ * (default 3000, above 0 and at most 100 000). On iOS 18+ results are
456
+ * confined to that area; before 18 it is a strong hint and a farther result
457
+ * can come back — each result's `distanceM` says how far. At most `limit`
458
+ * results (default 8, from 1; more than 25 is capped at 25).
459
+ *
460
+ * Network, Apple's servers. `[]` = Apple looked and found nothing. iOS only:
461
+ * throws UNAVAILABLE on Android or a build without AdaMaps.
462
+ */
463
+ search(query: string, opts?: { near?: MapsCoordinate; radiusM?: number; limit?: number }): Promise<MapsPlace[]>;
464
+ /**
465
+ * What is at a coordinate — its address and name.
466
+ *
467
+ * iOS AND Android (the platform geocoder); needs no AdaMaps module. The
468
+ * result's `coordinate` is the point you asked about, and its
469
+ * `category` / `distanceM` are null. `null` = nothing there.
470
+ *
471
+ * Network, not on-device. iOS reports a rate limit and a lost connection as
472
+ * the SAME error, so OFFLINE here can also mean "too many requests". Android's
473
+ * geocoder refuses without location permission (UNAVAILABLE) — that is the
474
+ * platform's rule, not a location read.
475
+ */
476
+ reverseGeocode(at: MapsCoordinate): Promise<MapsPlace | null>;
477
+ /**
478
+ * A walking route between two coordinates YOU supply.
479
+ *
480
+ * `null` = Apple found no route — never a fake 0 m route, which would read as
481
+ * "you have arrived". Walking is the only mode; any other refuses by name.
482
+ * Network, Apple's servers. iOS only: throws UNAVAILABLE on Android or a build
483
+ * without AdaMaps.
484
+ */
485
+ route(from: MapsCoordinate, to: MapsCoordinate, opts?: { mode?: 'walking' }): Promise<MapsRoute | null>;
486
+ }
487
+
488
+ export interface BluetoothAPI {
489
+ /** Is BLE hardware present and permitted? */
490
+ isSupported(): Promise<boolean>;
491
+ /** Is the radio switched on? (Separate from permission — both can block.) */
492
+ isEnabled(): Promise<boolean>;
493
+ /**
494
+ * Scan for advertising devices for `timeoutMs`, then stop.
495
+ *
496
+ * Bounded rather than open-ended on purpose: a scan left running is a
497
+ * battery drain the caller cannot see, and the mistake is invisible until a
498
+ * user notices their phone is flat.
499
+ */
500
+ scan(opts?: { timeoutMs?: number; serviceUUIDs?: string[] }): Promise<BluetoothDevice[]>;
501
+ }
502
+
174
503
  export interface PluginNative {
175
504
  camera: CameraAPI;
176
505
  location: LocationAPI;
177
- storage: StorageAPI;
506
+ /**
507
+ * @deprecated The gate never hands this out — it uses the per-plugin
508
+ * `storageFor(pluginId)` and FAILS CLOSED (blocks) when it can't scope. A
509
+ * single shared storage across plugins was a cross-plugin read/write hole;
510
+ * there is no shared bucket any more. Present only so older providers still
511
+ * satisfy the type; new providers implement `storageFor`.
512
+ */
513
+ storage?: StorageAPI;
178
514
  /** @deprecated use `biometricsV2` for the native-backed API. This stub
179
515
  * remains for backwards compatibility. */
180
516
  biometrics: BiometricsAPI;
@@ -182,11 +518,44 @@ export interface PluginNative {
182
518
  notifications: NotificationsAPI;
183
519
  haptics: HapticsAPI;
184
520
  connectivity: ConnectivityAPI;
185
- fileSystem: FileSystemAPI;
521
+ /**
522
+ * @deprecated The gate never hands this out — it uses the per-plugin
523
+ * `fileSystemFor(pluginId)` and FAILS CLOSED (blocks) when it can't scope. A
524
+ * single shared filesystem across plugins was a cross-plugin read/write hole;
525
+ * there is no shared bucket any more. Present only so older providers still
526
+ * satisfy the type; new providers implement `fileSystemFor`.
527
+ */
528
+ fileSystem?: FileSystemAPI;
529
+ /**
530
+ * Per-plugin filesystem: returns a FileSystemAPI whose sandbox root is scoped
531
+ * to THIS plugin (`plugin-files/<pluginId>/`), so one plugin cannot read,
532
+ * write or reach another plugin's files even via traversal. `gatePluginNative`
533
+ * uses it (passing the plugin's id) to isolate each plugin. If it is absent or
534
+ * no id is known, the gate FAILS CLOSED (blocks) rather than fall back to a
535
+ * shared filesystem — there is no shared bucket.
536
+ */
537
+ fileSystemFor?(pluginId: string): FileSystemAPI;
538
+ /**
539
+ * Per-plugin storage: returns a StorageAPI whose KV namespace AND SQLite
540
+ * database are scoped to THIS plugin, so one plugin cannot read, overwrite,
541
+ * or query another plugin's key-value entries or tables. `gatePluginNative`
542
+ * uses it (passing the plugin's id) to isolate each plugin. Identity is
543
+ * threaded through the TRUSTED provider here — never a caller-supplied
544
+ * key/table prefix. If it is absent or no id is known, the gate FAILS CLOSED
545
+ * (blocks) rather than fall back to a shared store — there is no shared bucket.
546
+ */
547
+ storageFor?(pluginId: string): StorageAPI;
186
548
  cookies: CookiesAPI;
187
549
  sharing: SharingAPI;
188
550
  scanner: ScannerAPI;
189
551
  nfc: NfcAPI;
552
+ /** SHAPE ONLY in this build — see BluetoothAPI. Present so it fails by name. */
553
+ bluetooth: BluetoothAPI;
554
+ /** SHAPE ONLY in this build — see VisionAPI. On-device image understanding. */
555
+ vision: VisionAPI;
556
+ /** Place search, reverse geocoding, walking routes — see MapsAPI. Sends
557
+ * queries to Apple's map servers; NOT on-device. */
558
+ maps: MapsAPI;
190
559
  }
191
560
 
192
561
  // ── Native capability flags (manifest declaration) ──────────
@@ -199,7 +568,20 @@ export interface NativeCapabilities {
199
568
  backgroundTasks?: boolean;
200
569
  fileSystem?: boolean;
201
570
  notifications?: boolean;
571
+ /** BLE. Declaring it passes the gate but every call refuses by name until
572
+ * the native side exists — see BluetoothAPI. */
202
573
  bluetooth?: boolean;
574
+ /** On-device image understanding (OCR, labels, frame quality). Refuses by
575
+ * name until the native module lands — see VisionAPI. */
576
+ vision?: boolean;
577
+ /** Place search, reverse geocoding, walking routes. Means "may send place
578
+ * queries to Apple Maps" — network, not on-device. See MapsAPI. */
579
+ maps?: boolean;
580
+ /** The live voice SESSION (props.voice). A privacy surface — a declaring
581
+ * plugin can read the conversation transcript — so it is gated like any
582
+ * other capability, and coupled to the platform's unsuppressable mic
583
+ * indicator. See VoiceAPI. */
584
+ voice?: boolean;
203
585
  haptics?: boolean;
204
586
  /** Read the platform cookie jar after a WebView login flow. */
205
587
  cookies?: boolean;
@@ -258,7 +640,43 @@ export interface ThemeTokens {
258
640
 
259
641
  export interface PluginProps {
260
642
  bridge: PluginBridge;
261
- native: PluginNative;
643
+ /**
644
+ * L2 Device Primitives — the camera/location/storage/haptics/… surface, gated
645
+ * to the capabilities this plugin declared. This is the canonical name (R4);
646
+ * the underlying L1 native modules keep the `native` name, but what a plugin
647
+ * receives is `props.device`.
648
+ */
649
+ device: PluginNative;
650
+ /**
651
+ * @deprecated Renamed to `device` in R4. Still passed (the SAME gated object)
652
+ * so existing/remote plugin bundles keep working — migrate to `props.device`.
653
+ */
654
+ native?: PluginNative;
655
+ /**
656
+ * L3 DeviceState — the SEMANTIC layer (`props.deviceState.spatial/visual/…`),
657
+ * gated to the domains this plugin declared in `stateDomains`. Reads answer
658
+ * `unknown` rather than throwing; a domain the build has no engine for, or the
659
+ * plugin did not declare, returns an explicit refusal.
660
+ *
661
+ * REQUIRED, deliberately: an optional field is exactly the compile-time escape
662
+ * hatch that let a renderer ship without it (props.deviceState === undefined,
663
+ * which crashed on drawer/card). Every renderer builds this through
664
+ * resolvePluginRuntimeProps, so the constructor side always has it; making it
665
+ * required means a NEW renderer that forgets to pass it fails to compile.
666
+ */
667
+ deviceState: DeviceState;
668
+ /**
669
+ * The live conversational SESSION, gated to plugins that declared
670
+ * `capabilities: { voice: true }`. A SESSION, not a device sensor: start/stop
671
+ * the loop, read + subscribe to the transcript, and speak first via
672
+ * `prompt()`. The mic-active indicator is platform-owned and cannot be
673
+ * suppressed from here.
674
+ *
675
+ * REQUIRED, like `deviceState`: every renderer builds it through
676
+ * resolvePluginRuntimeProps, so the object is always present. An undeclared
677
+ * plugin gets a refusal (reads say so, actions throw) — never a fake `idle`.
678
+ */
679
+ voice: VoiceAPI;
262
680
  theme: ThemeTokens;
263
681
  context: PluginContext;
264
682
  /** Engine-supplied props from `sys.focusUiPlugin(pluginId, props)`.
@@ -290,6 +708,11 @@ export interface PluginManifest {
290
708
  hooks?: PluginHooks;
291
709
  channels?: string[] | { events: string[]; actions: string[] };
292
710
  capabilities?: Record<string, unknown>;
711
+ /** L3 DeviceState domains this plugin reads (props.deviceState), as Core passes
712
+ * them through from the solution's declaration. Absent → NO domains (fail
713
+ * closed): the loaded bundle's own `stateDomains` is never consulted when a
714
+ * manifest exists. Read via resolvePluginRuntimeProps. */
715
+ stateDomains?: DeviceBridgeDomain[];
293
716
  _source?: string;
294
717
  _connectorId?: string;
295
718
  _mcpEndpoint?: string;
@@ -301,7 +724,13 @@ export interface RegisteredUIPlugin {
301
724
  id: string;
302
725
  type: 'ui' | 'hybrid';
303
726
  capabilities?: NativeCapabilities;
727
+ /** L3 DeviceState domains this plugin's UI reads (props.deviceState). The
728
+ * trusted source for a HOST-registered plugin (no server manifest), read by
729
+ * resolvePluginRuntimeProps. Carried by register(), same as registerTools. */
730
+ stateDomains?: DeviceBridgeDomain[];
304
731
  Component: React.FC<PluginProps>;
732
+ /** Optional `tools/` section — a plugin may ship both UI and local tools. */
733
+ tools?: DeviceToolDefinition[];
305
734
  }
306
735
 
307
736
  export interface RegisteredServicePlugin {
@@ -313,4 +742,56 @@ export interface RegisteredServicePlugin {
313
742
  onStop?: () => Promise<void> | void;
314
743
  }
315
744
 
316
- export type RegisteredPlugin = RegisteredUIPlugin | RegisteredServicePlugin;
745
+ // ── Device tools (a plugin's `tools/` section) ──────────────
746
+
747
+ /** What a device tool implementation is handed at call time. */
748
+ export interface DeviceToolContext {
749
+ bridge: PluginBridge;
750
+ /** Device Bridge — camera, location, sensors, storage. */
751
+ native: PluginNative;
752
+ /**
753
+ * L3 semantic DeviceState (visual/spatial/activity/…), gated to the domains
754
+ * the plugin declared in `stateDomains`. Every domain is always present; an
755
+ * undeclared one answers with an explicit "not declared" rather than data —
756
+ * the same gating a UI plugin's `props.deviceState` gets. This is what lets an
757
+ * EXTERNAL tool (e.g. Ada Guide's `guide.observe`) read `ctx.deviceState.visual`
758
+ * over the public surface, with no SDK-internal access.
759
+ *
760
+ * `subscribe` refuses (RESTRICTED) on every domain: a call has no lifetime to
761
+ * own a subscription, and the engines outlive it. Read instead.
762
+ */
763
+ deviceState: DeviceState;
764
+ /** Cooperative cancellation: caller cancelled, or the session went away. */
765
+ signal?: AbortSignal;
766
+ }
767
+
768
+ export type DeviceToolFn = (args: any, ctx: DeviceToolContext) => Promise<any> | any;
769
+
770
+ export interface DeviceToolDefinition {
771
+ /** Domain name, as the catalog and the caller see it. e.g. "guide.observe" */
772
+ name: string;
773
+ description?: string;
774
+ /** JSON Schema for arguments — advertised to Core, not enforced here. */
775
+ input_schema?: Record<string, unknown>;
776
+ run: DeviceToolFn;
777
+ }
778
+
779
+ /** A plugin that ships tools and no UI at all. */
780
+ export interface RegisteredToolsPlugin {
781
+ id: string;
782
+ type: 'tools';
783
+ capabilities?: NativeCapabilities;
784
+ /**
785
+ * L3 DeviceState domains this plugin's tools read via `ctx.deviceState` (e.g.
786
+ * `['visual']`). For a remote bundle this is the author's statement, not a
787
+ * grant: the tools get the manifest's `stateDomains` (resolvePluginGrants),
788
+ * and a domain declared here but not there is denied with a warning.
789
+ */
790
+ stateDomains?: DeviceBridgeDomain[];
791
+ tools: DeviceToolDefinition[];
792
+ }
793
+
794
+ export type RegisteredPlugin =
795
+ | RegisteredUIPlugin
796
+ | RegisteredServicePlugin
797
+ | RegisteredToolsPlugin;