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

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/index.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  // Registration API
8
- export { register, registerService } from './registration';
9
- import { register, registerService } from './registration';
10
- export const PluginSDK = { register, registerService } as const;
8
+ export { register, registerService, registerTools } from './registration';
9
+ import { register, registerService, registerTools } from './registration';
10
+ export const PluginSDK = { register, registerService, registerTools } as const;
11
11
 
12
12
  // Types
13
13
  export type {
@@ -24,9 +24,17 @@ export type {
24
24
  ConnectivityAPI,
25
25
  FileSystemAPI,
26
26
  CookiesAPI,
27
+ WebViewCookie,
27
28
  SharingAPI,
28
29
  ScannerAPI,
29
30
  NfcAPI,
31
+ BluetoothAPI,
32
+ BluetoothDevice,
33
+ VisionAPI,
34
+ VisionText,
35
+ VisionLabel,
36
+ VisionObject,
37
+ VisionFrameQuality,
30
38
  BiometricsAPI,
31
39
  BiometricsAPIv2,
32
40
  NativeCapabilities,
@@ -36,8 +44,28 @@ export type {
36
44
  RegisteredPlugin,
37
45
  RegisteredUIPlugin,
38
46
  RegisteredServicePlugin,
47
+ RegisteredToolsPlugin,
48
+ DeviceToolContext,
49
+ DeviceToolDefinition,
50
+ DeviceToolFn,
39
51
  } from './types';
40
52
 
53
+ // Voice — the live conversational SESSION surface (props.voice).
54
+ export type {
55
+ VoiceAPI,
56
+ VoiceState,
57
+ VoiceSnapshot,
58
+ VoiceTranscriptEntry,
59
+ VoiceStartOptions,
60
+ VoiceEvent,
61
+ VoiceSubscription,
62
+ VoiceCaller,
63
+ VoiceSessionProvider,
64
+ } from './voice';
65
+ // The privileged platform caller (a value) — the host injects the owner-aware
66
+ // provider and its own surfaces call it as this caller. See VoiceSessionProvider.
67
+ export { PLATFORM_VOICE_CALLER } from './voice';
68
+
41
69
  // RN primitives (plugins import from the SDK, not from react-native)
42
70
  export {
43
71
  View,
@@ -72,5 +100,9 @@ export {
72
100
  } from './components';
73
101
 
74
102
  // Theme + API hooks
103
+ // Showing the camera is a COMPONENT, not a native module handed to bundles —
104
+ // one door, one lock. See CameraPreview.tsx for why.
105
+ export { CameraPreview } from './CameraPreview';
106
+ export type { CameraPreviewProps } from './CameraPreview';
75
107
  export { usePluginTheme, setPluginThemeColors } from './usePluginTheme';
76
108
  export { useApi } from './useApi';
@@ -3,29 +3,68 @@
3
3
  * Solution developers use these to register their plugins.
4
4
  */
5
5
  import type {
6
+ DeviceToolDefinition,
6
7
  NativeCapabilities,
7
8
  PluginHooks,
8
9
  PluginProps,
9
10
  RegisteredUIPlugin,
10
11
  RegisteredServicePlugin,
12
+ RegisteredToolsPlugin,
11
13
  } from './types';
14
+ import type { DeviceBridgeDomain } from './device-state';
12
15
 
13
16
  /**
14
17
  * Register a UI or hybrid plugin (has a visual component).
18
+ *
19
+ * `tools` is optional: a plugin may ship UI and local tools together. Tools are
20
+ * picked up when the bundle loads, independently of whether the UI is mounted.
15
21
  */
16
22
  export function register(
17
23
  id: string,
18
24
  config: {
19
25
  type?: 'ui' | 'hybrid';
20
26
  capabilities?: NativeCapabilities;
27
+ /** L3 DeviceState domains this plugin's UI reads via props.deviceState (e.g.
28
+ * ['spatial','visual']). Carried through so a HOST-registered plugin (no
29
+ * server manifest) can declare its domains — resolvePluginRuntimeProps reads
30
+ * this as the trusted source when there is no manifest. Dropping it here left
31
+ * every registered UI plugin with no L3, silently. Mirrors registerTools. */
32
+ stateDomains?: DeviceBridgeDomain[];
21
33
  Component: React.FC<PluginProps>;
34
+ tools?: DeviceToolDefinition[];
22
35
  },
23
36
  ): RegisteredUIPlugin {
24
37
  return {
25
38
  id,
26
39
  type: config.type || 'ui',
27
40
  capabilities: config.capabilities,
41
+ stateDomains: config.stateDomains,
28
42
  Component: config.Component,
43
+ tools: config.tools,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Register a tools-only plugin — a `tools/` section with no UI at all.
49
+ *
50
+ * Nothing here mounts. The tools become callable as soon as the bundle is
51
+ * evaluated, and stay callable with nothing of the plugin on screen.
52
+ */
53
+ export function registerTools(
54
+ id: string,
55
+ config: {
56
+ capabilities?: NativeCapabilities;
57
+ /** L3 DeviceState domains the tools may read via ctx.deviceState (e.g. ['visual']). */
58
+ stateDomains?: DeviceBridgeDomain[];
59
+ tools: DeviceToolDefinition[];
60
+ },
61
+ ): RegisteredToolsPlugin {
62
+ return {
63
+ id,
64
+ type: 'tools',
65
+ capabilities: config.capabilities,
66
+ stateDomains: config.stateDomains,
67
+ tools: config.tools,
29
68
  };
30
69
  }
31
70
 
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,171 @@ 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
+ export interface BluetoothAPI {
356
+ /** Is BLE hardware present and permitted? */
357
+ isSupported(): Promise<boolean>;
358
+ /** Is the radio switched on? (Separate from permission — both can block.) */
359
+ isEnabled(): Promise<boolean>;
360
+ /**
361
+ * Scan for advertising devices for `timeoutMs`, then stop.
362
+ *
363
+ * Bounded rather than open-ended on purpose: a scan left running is a
364
+ * battery drain the caller cannot see, and the mistake is invisible until a
365
+ * user notices their phone is flat.
366
+ */
367
+ scan(opts?: { timeoutMs?: number; serviceUUIDs?: string[] }): Promise<BluetoothDevice[]>;
368
+ }
369
+
174
370
  export interface PluginNative {
175
371
  camera: CameraAPI;
176
372
  location: LocationAPI;
177
- storage: StorageAPI;
373
+ /**
374
+ * @deprecated The gate never hands this out — it uses the per-plugin
375
+ * `storageFor(pluginId)` and FAILS CLOSED (blocks) when it can't scope. A
376
+ * single shared storage across plugins was a cross-plugin read/write hole;
377
+ * there is no shared bucket any more. Present only so older providers still
378
+ * satisfy the type; new providers implement `storageFor`.
379
+ */
380
+ storage?: StorageAPI;
178
381
  /** @deprecated use `biometricsV2` for the native-backed API. This stub
179
382
  * remains for backwards compatibility. */
180
383
  biometrics: BiometricsAPI;
@@ -182,11 +385,41 @@ export interface PluginNative {
182
385
  notifications: NotificationsAPI;
183
386
  haptics: HapticsAPI;
184
387
  connectivity: ConnectivityAPI;
185
- fileSystem: FileSystemAPI;
388
+ /**
389
+ * @deprecated The gate never hands this out — it uses the per-plugin
390
+ * `fileSystemFor(pluginId)` and FAILS CLOSED (blocks) when it can't scope. A
391
+ * single shared filesystem across plugins was a cross-plugin read/write hole;
392
+ * there is no shared bucket any more. Present only so older providers still
393
+ * satisfy the type; new providers implement `fileSystemFor`.
394
+ */
395
+ fileSystem?: FileSystemAPI;
396
+ /**
397
+ * Per-plugin filesystem: returns a FileSystemAPI whose sandbox root is scoped
398
+ * to THIS plugin (`plugin-files/<pluginId>/`), so one plugin cannot read,
399
+ * write or reach another plugin's files even via traversal. `gatePluginNative`
400
+ * uses it (passing the plugin's id) to isolate each plugin. If it is absent or
401
+ * no id is known, the gate FAILS CLOSED (blocks) rather than fall back to a
402
+ * shared filesystem — there is no shared bucket.
403
+ */
404
+ fileSystemFor?(pluginId: string): FileSystemAPI;
405
+ /**
406
+ * Per-plugin storage: returns a StorageAPI whose KV namespace AND SQLite
407
+ * database are scoped to THIS plugin, so one plugin cannot read, overwrite,
408
+ * or query another plugin's key-value entries or tables. `gatePluginNative`
409
+ * uses it (passing the plugin's id) to isolate each plugin. Identity is
410
+ * threaded through the TRUSTED provider here — never a caller-supplied
411
+ * key/table prefix. If it is absent or no id is known, the gate FAILS CLOSED
412
+ * (blocks) rather than fall back to a shared store — there is no shared bucket.
413
+ */
414
+ storageFor?(pluginId: string): StorageAPI;
186
415
  cookies: CookiesAPI;
187
416
  sharing: SharingAPI;
188
417
  scanner: ScannerAPI;
189
418
  nfc: NfcAPI;
419
+ /** SHAPE ONLY in this build — see BluetoothAPI. Present so it fails by name. */
420
+ bluetooth: BluetoothAPI;
421
+ /** SHAPE ONLY in this build — see VisionAPI. On-device image understanding. */
422
+ vision: VisionAPI;
190
423
  }
191
424
 
192
425
  // ── Native capability flags (manifest declaration) ──────────
@@ -199,7 +432,17 @@ export interface NativeCapabilities {
199
432
  backgroundTasks?: boolean;
200
433
  fileSystem?: boolean;
201
434
  notifications?: boolean;
435
+ /** BLE. Declaring it passes the gate but every call refuses by name until
436
+ * the native side exists — see BluetoothAPI. */
202
437
  bluetooth?: boolean;
438
+ /** On-device image understanding (OCR, labels, frame quality). Refuses by
439
+ * name until the native module lands — see VisionAPI. */
440
+ vision?: boolean;
441
+ /** The live voice SESSION (props.voice). A privacy surface — a declaring
442
+ * plugin can read the conversation transcript — so it is gated like any
443
+ * other capability, and coupled to the platform's unsuppressable mic
444
+ * indicator. See VoiceAPI. */
445
+ voice?: boolean;
203
446
  haptics?: boolean;
204
447
  /** Read the platform cookie jar after a WebView login flow. */
205
448
  cookies?: boolean;
@@ -258,7 +501,43 @@ export interface ThemeTokens {
258
501
 
259
502
  export interface PluginProps {
260
503
  bridge: PluginBridge;
261
- native: PluginNative;
504
+ /**
505
+ * L2 Device Primitives — the camera/location/storage/haptics/… surface, gated
506
+ * to the capabilities this plugin declared. This is the canonical name (R4);
507
+ * the underlying L1 native modules keep the `native` name, but what a plugin
508
+ * receives is `props.device`.
509
+ */
510
+ device: PluginNative;
511
+ /**
512
+ * @deprecated Renamed to `device` in R4. Still passed (the SAME gated object)
513
+ * so existing/remote plugin bundles keep working — migrate to `props.device`.
514
+ */
515
+ native?: PluginNative;
516
+ /**
517
+ * L3 DeviceState — the SEMANTIC layer (`props.deviceState.spatial/visual/…`),
518
+ * gated to the domains this plugin declared in `stateDomains`. Reads answer
519
+ * `unknown` rather than throwing; a domain the build has no engine for, or the
520
+ * plugin did not declare, returns an explicit refusal.
521
+ *
522
+ * REQUIRED, deliberately: an optional field is exactly the compile-time escape
523
+ * hatch that let a renderer ship without it (props.deviceState === undefined,
524
+ * which crashed on drawer/card). Every renderer builds this through
525
+ * resolvePluginRuntimeProps, so the constructor side always has it; making it
526
+ * required means a NEW renderer that forgets to pass it fails to compile.
527
+ */
528
+ deviceState: DeviceState;
529
+ /**
530
+ * The live conversational SESSION, gated to plugins that declared
531
+ * `capabilities: { voice: true }`. A SESSION, not a device sensor: start/stop
532
+ * the loop, read + subscribe to the transcript, and speak first via
533
+ * `prompt()`. The mic-active indicator is platform-owned and cannot be
534
+ * suppressed from here.
535
+ *
536
+ * REQUIRED, like `deviceState`: every renderer builds it through
537
+ * resolvePluginRuntimeProps, so the object is always present. An undeclared
538
+ * plugin gets a refusal (reads say so, actions throw) — never a fake `idle`.
539
+ */
540
+ voice: VoiceAPI;
262
541
  theme: ThemeTokens;
263
542
  context: PluginContext;
264
543
  /** Engine-supplied props from `sys.focusUiPlugin(pluginId, props)`.
@@ -290,6 +569,10 @@ export interface PluginManifest {
290
569
  hooks?: PluginHooks;
291
570
  channels?: string[] | { events: string[]; actions: string[] };
292
571
  capabilities?: Record<string, unknown>;
572
+ /** L3 DeviceState domains this plugin reads (props.deviceState), as Core passes
573
+ * them through from the bundle's declaration. Absent → fall back to the loaded
574
+ * bundle's own `stateDomains`. Read via resolvePluginRuntimeProps. */
575
+ stateDomains?: DeviceBridgeDomain[];
293
576
  _source?: string;
294
577
  _connectorId?: string;
295
578
  _mcpEndpoint?: string;
@@ -301,7 +584,13 @@ export interface RegisteredUIPlugin {
301
584
  id: string;
302
585
  type: 'ui' | 'hybrid';
303
586
  capabilities?: NativeCapabilities;
587
+ /** L3 DeviceState domains this plugin's UI reads (props.deviceState). The
588
+ * trusted source for a HOST-registered plugin (no server manifest), read by
589
+ * resolvePluginRuntimeProps. Carried by register(), same as registerTools. */
590
+ stateDomains?: DeviceBridgeDomain[];
304
591
  Component: React.FC<PluginProps>;
592
+ /** Optional `tools/` section — a plugin may ship both UI and local tools. */
593
+ tools?: DeviceToolDefinition[];
305
594
  }
306
595
 
307
596
  export interface RegisteredServicePlugin {
@@ -313,4 +602,52 @@ export interface RegisteredServicePlugin {
313
602
  onStop?: () => Promise<void> | void;
314
603
  }
315
604
 
316
- export type RegisteredPlugin = RegisteredUIPlugin | RegisteredServicePlugin;
605
+ // ── Device tools (a plugin's `tools/` section) ──────────────
606
+
607
+ /** What a device tool implementation is handed at call time. */
608
+ export interface DeviceToolContext {
609
+ bridge: PluginBridge;
610
+ /** Device Bridge — camera, location, sensors, storage. */
611
+ native: PluginNative;
612
+ /**
613
+ * L3 semantic DeviceState (visual/spatial/activity/…), gated to the domains
614
+ * the plugin declared in `stateDomains`. Every domain is always present; an
615
+ * undeclared one answers with an explicit "not declared" rather than data —
616
+ * the same gating a UI plugin's `props.deviceState` gets. This is what lets an
617
+ * EXTERNAL tool (e.g. Ada Guide's `guide.observe`) read `ctx.deviceState.visual`
618
+ * over the public surface, with no SDK-internal access.
619
+ */
620
+ deviceState: DeviceState;
621
+ /** Cooperative cancellation: caller cancelled, or the session went away. */
622
+ signal?: AbortSignal;
623
+ }
624
+
625
+ export type DeviceToolFn = (args: any, ctx: DeviceToolContext) => Promise<any> | any;
626
+
627
+ export interface DeviceToolDefinition {
628
+ /** Domain name, as the catalog and the caller see it. e.g. "guide.observe" */
629
+ name: string;
630
+ description?: string;
631
+ /** JSON Schema for arguments — advertised to Core, not enforced here. */
632
+ input_schema?: Record<string, unknown>;
633
+ run: DeviceToolFn;
634
+ }
635
+
636
+ /** A plugin that ships tools and no UI at all. */
637
+ export interface RegisteredToolsPlugin {
638
+ id: string;
639
+ type: 'tools';
640
+ capabilities?: NativeCapabilities;
641
+ /**
642
+ * L3 DeviceState domains this plugin's tools may read via `ctx.deviceState`
643
+ * (e.g. `['visual']`). Undeclared domains answer "not declared". Same
644
+ * mechanism as a UI plugin's manifest `stateDomains`.
645
+ */
646
+ stateDomains?: DeviceBridgeDomain[];
647
+ tools: DeviceToolDefinition[];
648
+ }
649
+
650
+ export type RegisteredPlugin =
651
+ | RegisteredUIPlugin
652
+ | RegisteredServicePlugin
653
+ | RegisteredToolsPlugin;