SDK 1.0.0, device contract 1.0.0, projection format 1. The helper generates a complete minimal counter plugin from the same starter used in Vibe. The SDK source and validators supplied with your tools are authoritative if their versions differ from this reference.
1.0.0
1
Host runtimes run on demand. Enabling a plugin makes it available on the Dock (summary runtime: 'standby'); it does not start JavaScript. The Desktop host starts the runtime when the Dock opens the plugin (or when the Desktop simulator attaches to it) and completes the Dock activation once activate returns. When the plugin is hidden, meaning the Dock shows another plugin, the user pressed Back or the Dock disconnected, the runtime keeps running for RUNTIME_LIFECYCLE.idleStopMs (60 seconds) and is then stopped; reopening within that window reuses it. Native-execution projections (deviceExecution(...) === 'native') never start a host runtime. Page previews, Vibe and Agent-service previews and the standalone toolkit are separate fixture runtimes and are always visible.
runtime: 'standby'
activate
RUNTIME_LIFECYCLE.idleStopMs
deviceExecution(...) === 'native'
Do not rely on in-memory state surviving between openings. Persist what users expect to resume (timer deadlines as wall-clock times, current positions, scores, selections) with ctx.store (store permission) and restore it in activate before the first publish. Recompute elapsed time from the wall clock rather than counting ticks. Opaque photo/video IDs are not persisted; refresh them after a restart.
ctx.store
store
ctx.view.visible is true while the Dock or the simulator shows the plugin. ctx.view.subscribe(listener) calls the listener with each change (not the current value), returns an unsubscribe function and is cleared when ctx.signal aborts. While hidden, pause timers, polling, slideshows and render loops, stop pulling network frames and skip publications that nobody sees; when visible again refresh once and clamp elapsed simulation time so a game does not jump. After activate returns, frames.present/frames.submit wait while hidden: the newest submission is held until the plugin is shown again, older waiting submissions resolve {accepted:false}, so a loop that awaits present pauses naturally. Frames presented during activate are never held, so awaiting a first frame cannot stall startup. Previews report always visible. Older hosts lack ctx.view (and never stop hidden runtimes); use ctx.view?.visible ?? true and ctx.view?.subscribe(...) in portable plugins.
ctx.view.visible
ctx.view.subscribe(listener)
ctx.signal
frames.present
frames.submit
{accepted:false}
present
ctx.view
ctx.view?.visible ?? true
ctx.view?.subscribe(...)
Closing the Desktop main window hides it to the system tray and preserves host runtimes, previews and the App MCP service. Click the tray icon to restore the window; Quit PexarBolt stops services. Window visibility does not change the host/native execution requirements or ctx.view.
A Dock disconnect retires the device view; the host runtime stays Running only for the idle window, and the next open restarts it if it has stopped. Updated App/firmware automatically restore the currently selected host plugin, latest state and cached frame after reconnecting or losing the five-second lease. Recovery uses bounded backoff and respects Back, disabled plugins and revoked grants; it never replays control/HID actions. Do not implement Idle/Enable or repeated open_on_device loops as recovery. Plugin network sources still need cancellable, bounded retries. Verify live and paused frames plus controls on real hardware; fixture checks cannot prove reconnect recovery. The "Dock reconnection" section of the plugin reliability guide (plugin-optimization.md) records the host recovery schedule.
plugin-optimization.md
Follow exported PLUGIN_OPTIMIZATION_POLICY / context.optimizationPolicy: prompt activation (5s startup deadline), one serialized publisher coalescing latest state at least 300ms apart including startup, bounded queues/concurrency/caches, and complete abort cleanup. Prefer subscriptions; never overlap async polling loops. Per-plugin limits are 4 publishes/s, 10 logs/s, 20 HID calls/s, 4 concurrent HTTP and 16 outstanding/100-per-second SDK data requests. They are ceilings, not target rates. Use one external-volume collection subscription; native watcher allocation belongs to the host (one recursive watcher per volume on macOS/Windows). Avoid full rescan per event and snapshot polling. Names/extensions do not shrink traversal quotas. Transfer-only UIs need no metadata snapshot. Keep serialized callbacks under 10s, pace snapshot pages by 20ms and validate mountId/epoch/revision after awaits.
PLUGIN_OPTIMIZATION_POLICY
CLI/MCP/Vibe checks return verification:{mode:'fixture',liveVerified:false,optimizationReview:'required',policyVersion:2,requiredLiveChecks:[...]}. This is an explicit pending review, not an automated performance audit. Test actual bursts, large/deep/over-quota volumes, disconnect/remount, stop/revoke, sleep/resume and interrupted transfers before claiming live verification. For unavailable/runtime_fault inspect status/logs: heartbeat_timeout, startup_timeout, activation_failed, event_timeout and renderer_* are separate causes. Fix/update a stalled host instead of increasing plugin polling/quotas or disabling watchdogs.
verification:{mode:'fixture',liveVerified:false,optimizationReview:'required',policyVersion:2,requiredLiveChecks:[...]}
Transfer lifecycle: running may pause/resume or end as completed/cancelled/unknown. Cancellation takes precedence over finished in the same publisher observation, even at 100%. Unpublication/publisher loss without an explicit terminal outcome is unknown; no timeout or silence heuristic proves cancellation or completion. Terminal observations do not resume; a new observation has a new id. Optional reason is publisher_cancelled, observation_ended (including directory subscription removal), volume_unmounted or backend_unavailable. Non-running bytesPerSecond/estimatedSecondsRemaining are null. Maintain compatibility when reason is absent.
reason
Unmount emits unknown for remaining active observations before the volume event and preserves queued explicit terminal outcomes. Backend loss keeps previously confirmed results and marks active observations unknown. closed/abort stops monitoring; consumers must mark any still-active local tasks unknown and suppress late work. Neither close nor these read-only APIs can cancel/pause/resume Finder transfers. Ejection is a distinct, deliberate whole-device action. Retain bounded terminal notices keyed by volumeId+mountId+id; remount invalidates old work, while snapshot expiry/eviction and coalesced events never imply completion. On transfer-reset replace current tasks with transferSnapshot and mark missing active observations unknown. Provide paused, cancelled and interrupted fixture scenarios.
Capacity and overload: running plugins, previews and simulators have no fixed concurrent-instance limit (LIMITS.instances and capacity.limit are null; counts describe activity). Sandbox isolation, per-plugin traffic limits, operation queues and watchdogs still apply. A simulator attached to an enabled plugin starts its runtime on demand and keeps it visible; closing the simulator hides it, and it stops after the idle window unless the Dock is showing it. An older App/toolkit may still report a four-instance limit; update and restart that host instead of changing the plugin. overloaded keeps its code and carries a reason and recovery guidance: for busy controls wait for pending work (at most 16 queued controls per instance); for recurring SDK traffic reduce publication, polling or concurrency. Preserve source on resource errors, stop automatic code repair and retry the check after resolving the actual resource or queue problem. Never automatically replay a mutation whose outcome is unknown.
LIMITS.instances
capacity.limit
null
overloaded
plugin.json host.mjs config.schema.json state.schema.json events.schema.json fixtures/default.json device.projection.json assets/*.png optional, declared in assetHashes
The manifest includes id, name, version (x.y.z), contract, host: {entry,sdk,permissions}, the three schema paths, layout: {}, and knob: {label,ccw?,cw?,press?}. Each dial action is {label,emit} and must name a declared event. The id starts with a lowercase letter and contains lowercase letters, numbers, dots or hyphens (maximum 80 characters). Keep ids stable across versions and distinct from bundled plugins.
id
name
version
contract
host: {entry,sdk,permissions}
layout: {}
knob: {label,ccw?,cw?,press?}
{label,emit}
Optional icon names a package-local square PNG up to 256x256, for example "icon": "assets/icon.png". Include this file in projection assetHashes even if no primitive references it. Use a real PNG, not an SVG, external URL or placeholder string. Keep an existing user-selected icon unless the user requests a change. Vibe's image picker saves the image into the current draft; read current files before editing so later turns retain it. The icon appears in the client, SDL and compatible physical Dock catalogs, separately from the Dock projection; it does not replace artwork inside the plugin. The App automatically transfers a thumbnail at the negotiated catalogIcons v1 size: 72x72 on current firmware, 48x48 on earlier firmware. No extra permission, Base64 source edit or runtime SDK call is needed. If only the physical catalog lacks custom icons, update both App and firmware. Packages without an icon remain valid; Music Lyrics shares the App's bundled fallback image, while other packages use the device's default icon.
icon
"icon": "assets/icon.png"
assetHashes
catalogIcons
host.mjs exports async function activate(ctx) and may return cleanup. Use browser ESM and relative package-local imports. Node, process, shell, filesystem, arbitrary network, eval and remote dependencies are unavailable. Stop timers and subscriptions when ctx.signal aborts. Await ctx.publish(state); handle aborted timer callbacks without unhandled rejections. Publication returns a host revision, not a device acknowledgment; the host retains the latest complete state and reconciles the device through leases (default device cadence 1 Hz). Startup must finish within five seconds and event handlers within ten.
host.mjs
async function activate(ctx)
ctx.publish(state)
await ctx.publish(completeState)
ctx.on(name, async ({eventId,context,signal}) => result)
ctx.http.request({method,path,json?,credential?,idempotencyKey?,signal?})
network
ctx.http.readBytes({path,credential?,signal?})
ctx.frames.start
.createCanvas
.font
.drawIcon
.present
.submit
.status
.stop
ctx.store.get(key)
.set(key,value)
.delete(key)
ctx.media.read()
.subscribe(listener)
media.read
ctx.dock.read()
.subscribe(listener, onError?)
dock.read
ctx.images.choose()
.show({id}, {fit,signal?})
.clear()
images.read
host.imageFolders
ctx.images.list(folderId, {recursive?,cursor?,limit?,signal?})
ctx.media.control(command,value?)
media.control
ctx.hid.consumer(command, {signal?})
hid.consumer
ctx.hid.keyboard({key,modifiers?}, {signal?})
hid.keyboard
ctx.hid.mouse(command, {signal?})
hid.mouse
ctx.log.info/warn/error(message,fields?)
Playback commands: previous, next, playpause, seek, shuffle, repeat. Availability depends on the host platform and player. Do not claim universal player or operating-system support.
previous
next
playpause
seek
shuffle
repeat
HID consumer commands: volumeUp, volumeDown, mute, playPause, nextTrack, previousTrack, stop. These send a real USB HID key press and release from the connected Dock to its USB host. They do not use the player's volume API. Declare hid.consumer in both host.permissions and each HID event's permissions; use write: true. The plugin must be installed and open on the connected physical Dock, with firmware advertising hidConsumer.version: 1, and the client must be running. SDK/contract remain compatible with 1.0.0; old conversations claiming that volume control is unavailable are outdated.
volumeUp
volumeDown
mute
playPause
nextTrack
previousTrack
stop
host.permissions
write: true
hidConsumer.version: 1
For a volume knob, merge these handlers into the existing activation and map knob.ccw.emit to volume.down, knob.cw.emit to volume.up. Keep the user's existing press behavior unless asked to change it:
knob.ccw.emit
volume.down
knob.cw.emit
volume.up
ctx.on('volume.down', async ({signal}) => ctx.hid.consumer('volumeDown', {signal})); ctx.on('volume.up', async ({signal}) => ctx.hid.consumer('volumeUp', {signal}));
Each event definition is { "context": { "type": "null" }, "write": true, "permissions": ["hid.consumer"] }. A call sends one step; media, keyboard and mouse share a total of 20 calls/second. Always return/check the EventResult; do not show an invented absolute volume or claim the OS changed it. Success means HID reports were written, not that the receiving OS/app applied them. Handle offline, unsupported (old firmware or unsupported action), unavailable (plugin not open or HID not ready), and permission_denied. On overloaded, stop repeated dispatch and reduce event frequency. Never retry an outcome: 'unknown' result: the input may already have been applied. Use the event signal to prevent canceled actions. Vibe, CLI and MCP fixture previews simulate all HID and never change system volume, send keys or move/click the pointer; no manual fixture branch is needed.
{ "context": { "type": "null" }, "write": true, "permissions": ["hid.consumer"] }
EventResult
offline
unsupported
unavailable
permission_denied
outcome: 'unknown'
Network requests use a relative path. host.network.originConfig names the configuration property containing the HTTPS origin (explicit localhost HTTP is supported for local services). Declare secrets as host.secrets: [{id,label}] and reference them with credential. Credentials are kept by the host; plugins never receive their values. Writes use the provided eventId as idempotencyKey; unknown write outcomes must not be automatically replayed.
path
host.network.originConfig
host.secrets: [{id,label}]
credential
eventId
idempotencyKey
A plugin reaches the network in one of two ways. NETWORK_ACCESS_CAPABILITY in the SDK describes both; MCP plugin_context and tool contexts return it as networkAccess.
NETWORK_ACCESS_CAPABILITY
plugin_context
networkAccess
ctx.http
sources
readBytes
localhost
http
https
ws
wss
allowPrivateNetwork: true
host.secrets
To show a LAN device's status on the Dock without the App, use sources. To change something from a control, use ctx.http: a LAN service without HTTPS needs a small HTTPS or localhost gateway on the computer.
Events schema is an object keyed by event name. Each value contains context JSON Schema, optional result JSON Schema, write: boolean, optional permissions. Handlers return {ok:true,result?} or {ok:false,reason,outcome?}. A hit with no context sends null, so its schema must accept null. Dial actions send no custom context. Mark external mutations as writes and declare the corresponding permission.
context
result
write: boolean
permissions
{ok:true,result?}
{ok:false,reason,outcome?}
Schemas use strict Ajv with explicit types/properties. No $ref, $dynamicRef, pattern, patternProperties or format. Provide defaults for required config properties. Fixture JSON must match the state schema exactly. Schema traversal is limited to 256 objects and depth 16; configuration must be an object and state may be any bounded JSON value. The host injects config.fixture (true only in fixture sessions); omit it from user-facing settings. On package upgrade, rollback and App restore, saved top-level settings forbidden by the current schema's additionalProperties: false are removed and missing defaults are filled; declared values are kept, including invalid ones that the user must correct.
$ref
$dynamicRef
pattern
patternProperties
format
config.fixture
additionalProperties: false
Keyboard and mouse require separate hid.keyboard and hid.mouse grants. Firmware must advertise hidKeyboard.version: 1 and/or hidMouse.version: 1; older firmware remains compatible with existing APIs but returns unsupported for these calls. Both use the Dock's USB HID interface, with the plugin installed and open and the desktop client running. The SDK/contract remains 1.0.0 with additive APIs.
hidKeyboard.version: 1
hidMouse.version: 1
export async function activate(ctx) { // Merge with state publication and other handlers for the plugin. ctx.on('copy', async ({signal}) => ctx.hid.keyboard({key: 'KeyC', modifiers: ['Primary']}, {signal})); ctx.on('palette', async ({signal}) => ctx.hid.keyboard({key: 'KeyP', modifiers: ['Primary', 'Shift']}, {signal})); ctx.on('click', async ({signal}) => ctx.hid.mouse({action: 'click', button: 'left'}, {signal})); ctx.on('right', async ({signal}) => ctx.hid.mouse({action: 'move', dx: 12, dy: 0}, {signal})); ctx.on('scroll.down', async ({signal}) => ctx.hid.mouse({action: 'scroll', delta: -1}, {signal})); }
In plugin.json, include "hid.keyboard" and "hid.mouse" in host.permissions only when needed. For example, events.schema.json declares "copy": {"context":{"type":"null"},"write":true,"permissions":["hid.keyboard"]} and "click": {"context":{"type":"null"},"write":true,"permissions":["hid.mouse"]}. Declare the other handlers similarly and map projection hits/dial actions to their event names. Do not call HID during activation, preview setup or autonomous background loops.
plugin.json
"hid.keyboard"
"hid.mouse"
events.schema.json
"copy": {"context":{"type":"null"},"write":true,"permissions":["hid.keyboard"]}
"click": {"context":{"type":"null"},"write":true,"permissions":["hid.mouse"]}
key
HID_KEYS
KeyA
KeyZ
Digit0
Digit9
F1
F12
Enter
Escape
Tab
Space
KeyC
C
modifiers
Control
Shift
Alt
Meta
Primary
{action:'click', button:'left'|'right'|'middle'}
{action:'move', dx, dy}
{action:'scroll', delta}
Return/check each result before subsequent actions. Cancellation cannot retract reports already sent. The firmware deduplicates sequences across all HID types and queues release reports after a partial/blocked send; an unknown result must not be replayed. A shortcut affects the receiving computer's current focus, and a click uses its current pointer position. These APIs do not select an application or window, read screen coordinates, register global shortcuts, access clipboard contents or grant arbitrary OS automation.
Declare "dock.read" in host.permissions. await ctx.dock.read() returns a DockStatusSnapshot from the host's shared physical-port cache. ctx.dock.subscribe(async snapshot => ..., onError?) emits the initial snapshot and changes; all subscribers in one plugin share a non-overlapping poll, at most once/second. Async listeners are awaited, so slow publication does not accumulate work. It returns an unsubscribe function and stops automatically on ctx.signal abort. read() returns immediately and starts a background refresh when needed; if a one-shot read returns loading, subscribe or read again later. Handle errors such as permission_denied with onError; declare the permission before using the API. Read-only status needs no event permission unless an event explicitly declares one.
"dock.read"
await ctx.dock.read()
DockStatusSnapshot
ctx.dock.subscribe(async snapshot => ..., onError?)
read()
loading
onError
Snapshot fields:
status
disconnected
ready
ports: []
updatedAt: null
updatedAt
dock.dashboard.live
ports
portId
connected
disabled
error
class
storage
display
tb_host
other
audio
available: false
unavailableReason
DockPortState
desktopStatus
online
temperature
level
normal
high
critical
fanMode
off
low
medium
Port identifiers on the current Dock: tb5_host, tb5_downstream_1, tb5_downstream_2, ethernet_10g, nvme_gen4x2, cfexpress_b, sd_uhs_ii, microsd_uhs_ii, displayport, hdmi, usb_c_pd_1, usb_c_pd_2, usb_c_10g_1, usb_c_10g_2, usb_c_10g_3, usb_a_10g_1, usb_a_10g_2, usb_a_480m_1, usb_a_480m_2, audio_in, audio_out, audio_combo.
tb5_host
tb5_downstream_1
tb5_downstream_2
ethernet_10g
nvme_gen4x2
cfexpress_b
sd_uhs_ii
microsd_uhs_ii
displayport
hdmi
usb_c_pd_1
usb_c_pd_2
usb_c_10g_1
usb_c_10g_2
usb_c_10g_3
usb_a_10g_1
usb_a_10g_2
usb_a_480m_1
usb_a_480m_2
audio_in
audio_out
audio_combo
export async function activate(ctx) { const publish = async snapshot => { await ctx.publish({ status: snapshot.status, connected: snapshot.ports.filter(port => port.status === 'connected').length, total: snapshot.ports.length, networkRate: snapshot.ports.find(port => port.portId === 'ethernet_10g')?.network?.negotiatedRate ?? '--', }); }; await publish(await ctx.dock.read()); return ctx.dock.subscribe(publish, error => ctx.log.warn(error.message)); }
Define those four fields in the state schema and fixture, and bind formatted values to the projection. Display the non-ready status clearly. This API exposes connection/negotiation status, not live throughput, watts, metric history, filesystem access, port power control or device ejection. It excludes mount paths, volume identities, Thunderbolt identifiers, raw serial paths and arbitrary protocol calls. A live plugin requires an updated runtime host and Dock firmware supporting dock.dashboard.query/live; standalone authoring needs no App.
dock.dashboard.query/live
Vibe and standalone fixture previews return a fixed sample with 22 ports, four connected (Thunderbolt host, Ethernet, NVMe and HDMI), plus normal temperature and low fan mode. They never read physical hardware. No fixture branch is needed for the default demo; use explicit config.fixture sample states to visually test offline, error or long-value cases. A fixture pass does not prove real hardware behavior.
{ "version": 1, "manifestHash": "BUILD", "assetHashes": {}, "width": 854, "height": 480, "header": { "height": 72, "type": "pexar-device-top-nav" }, "primitives": [], "hits": [] }
The top 72 pixels belong to the device navigation header. Every primitive/hit has positive w,h, with x>=0, y>=72, x+w<=854, y+h<=480. The preview is displayed at half scale, 427x240. For ordinary plugin layouts, keep controls, hit targets, feedback text and selection indicators at least 24 device pixels above the bottom edge (y+h<=456, or 12 preview pixels). This is authoring guidance; full-bleed backgrounds and media may still use the entire content rectangle. Preserve touch-target sizes and leave space between the footer and adjacent content.
w
h
x>=0
y>=72
x+w<=854
y+h<=480
y+h<=456
Primitives are a flat array of rect, text, image, progress, icon. Optional widgets add interactive JSON layouts (see below). No HTML/CSS/JS runs on the device. Unknown fields are rejected. Use the exact Primitive interface in the current SDK. Color/fill/stroke arrays are [r,g,b,alpha], each 0..255. Text font is 8..48, alignment is left/start/center/right, wrap is boolean. Rect radius is 0..8. border is four side widths 0..8, with color in stroke. Progress binds a number and divides by max (default 1).
rect
text
image
progress
widgets
Primitive
[r,g,b,alpha]
border
stroke
max
The default Web/App and LVGL controls follow the PexarBolt App palette: black background, #202020 control surfaces, white text, #8D8D94 secondary text and #53FFAF active state. Official examples read packages/ui-tokens/src/tokens.json when generating their projections. Use #E6434F for errors and #F2C800 for warnings, with a readable label. Authored RGBA colors still take precedence; photographs and user media retain their original colors. This is a visual default, not a new permission or projection field.
packages/ui-tokens/src/tokens.json
bind, selected, assetBind and hit disabled use dotted state paths, never expressions. Publish computed values and formatted strings in state. Images reference real PNG files through asset, optionally variants keyed by bound state. All files must be declared in assetHashes. Use appearance: "overlay" for a hit over an existing visual. Other hits show their label. A hit includes x,y,w,h,emit,label,context?; context must pass the event schema. React and SDL previews also accept an image assetBind pointing to bounded inline PNG artwork (at most 160x160); physical-device dynamic artwork transfer is not implemented.
bind
selected
assetBind
asset
variants
appearance: "overlay"
x,y,w,h,emit,label,context?
The trusted packaging helper calculates SHA-256 from the exact manifest bytes and PNG contents in its private snapshot. BUILD markers are accepted in author sources by development validation, but not in an installable release package.
Authoring image input is a local file: plugin_write({files:[{path:"assets/cover.png",source:"/absolute/cover.jpg"}], ...revisionArguments}), PluginSession.importImage(source, destination, revision?), or bolt-plugin image DIRECTORY SOURCE_IMAGE assets/cover.png. PNG, JPEG, WebP, GIF, BMP, AVIF and SVG are converted to static PNG files. Images preserve aspect ratio without upscaling, fit referenced projection bounds up to 854x480 (including fullscreen), and icons are padded to a transparent square up to 256x256. Aggregate image budgets below are enforced by compression and additional downscaling. Include new projection/manifest references in the same write; returned images contains final paths, dimensions and byte sizes. Image references and hashes update automatically. Animated input becomes one static frame, not playback.
plugin_write({files:[{path:"assets/cover.png",source:"/absolute/cover.jpg"}], ...revisionArguments})
PluginSession.importImage(source, destination, revision?)
bolt-plugin image DIRECTORY SOURCE_IMAGE assets/cover.png
images
All image formats use this file workflow by default, including PNG. plugin_read.files contains text; assets contains image byte sizes and dimensions. Binary bytes are omitted unless includeBinary:true is explicitly requested. Base64 and image Data URLs are legacy input options, not the recommended workflow. The local source file must exist on the machine running the authoring service; it is never a runtime dependency. Inputs are bounded to 10 MiB, 8192 pixels/side and 16 megapixels/image, with 10 MiB and 32 megapixels/write. Standalone check/package also normalize source-directory images in a private snapshot, keeping the originals editable. The release contract still requires actual PNG entries with finalized hashes; URLs, Base64 strings and original JPEG/WebP/SVG files are not projection assets.
plugin_read.files
assets
includeBinary:true
Package: 10 MiB total, 2 MiB/file, 512 entries; state and manifest 64 KiB; at most 256 projection nodes and depth 16. Vibe drafts use a smaller 2 MiB/128-file author budget. PNG assets: dimensions at most 854x480, 1 MiB compressed and 4 MiB decoded combined. Store: 1 MiB. Close unused previews to release resources; instance capacity is described under Optimization And Transfer Recovery.
Preview uses the actual Electron sandbox with fake brokers. HTTP/media reads return fixture data, external controls are simulated, and storage is private to the preview. Branch on config.fixture to publish realistic sample states. A passed preview proves package acceptance and the exercised sample behavior, not live service credentials, real player control or device compatibility.
Prioritize system resources in new plugins and AI edits. fontFace accepts system (Alibaba Sans Regular, default), system-medium, system-bold, or digits (42dot Sans Digits/Units). font is the size in device pixels, 8-48. Arbitrary font names, URLs and font files are rejected. Use digits for numeric readouts, system faces for general text.
fontFace
system
system-medium
system-bold
digits
font
Example icon: { "type":"icon", "icon":"volume", "x":16, "y":88, "w":40, "h":40, "font":24 }. Buttons also accept icon; omit label for icon-only buttons, whose id supplies the accessible name. A label displays icon and text. Names, not Unicode code points, are the API; the same glyph subset is bundled in the client and firmware.
{ "type":"icon", "icon":"volume", "x":16, "y":88, "w":40, "h":40, "font":24 }
label
Names: home, back, forward, up, down, play, pause, stop, previous, next, volume, mute, plus, minus, check, close, settings, refresh, power, save, download, upload, folder, file, image, video, music, wifi, bluetooth, usb, battery, search, info, warning, trash, edit, lock, unlock, eye, fullscreen, menu. SYSTEM_ICONS is authoritative. Use packaged PNGs for artwork or icons absent from the catalog.
home
back
forward
up
down
play
pause
volume
plus
minus
check
close
settings
refresh
power
save
download
upload
folder
file
video
music
wifi
bluetooth
usb
battery
search
info
warning
trash
edit
lock
unlock
eye
fullscreen
menu
SYSTEM_ICONS
Every node has a unique id (ASCII letter followed by letters/digits/underscore/hyphen, maximum 64), type, integer w/h, optional integer x/y. Roots default to x=0,y=72 and stay inside the content area. Child coordinates are parent-relative. container.layout is absolute (default), row or column; flow layouts ignore child x/y, preserve explicit sizes, and use padding and gap (0-64). Absolute coordinates include padding. Overflow clips unless scroll is x, y or both. Children may reach 4096 pixels to allow scrolling. Tabs reserve a 40-pixel strip.
type
w/h
x/y
x=0,y=72
container.layout
absolute
row
column
padding
gap
scroll
x
y
both
button
switch
checkbox
slider
stepper
min
step
pagination
radio
list
select
options
optionsBind
tabs
container
children
layout
Options are {label, value: string|number, disabled?: boolean} with unique values, maximum 64. optionsBind reads a state array; invalid arrays render empty and reject selection. Labels are at most 160 characters, string values 128. Numeric bounds are finite within +/-1,000,000, max > min, step >= 0.001. Publish new state after input: host state owns selection/toggles. disabled and visible are dotted state paths, inherited by children. Hidden/disabled nodes and inactive tab children cannot emit. Bindings are paths, not expressions.
{label, value: string|number, disabled?: boolean}
max > min
step >= 0.001
visible
Interactive nodes declare emit. Their context is always {id, value, context}, where the last field is the static node context or null. Declare this object in the event schema. Existing hits and dial events keep their original context (usually null); use separate events or anyOf when sharing handlers. Values are validated again against current state on input.
emit
{id, value, context}
anyOf
{ "id":"level", "type":"slider", "x":16, "y":120, "w":300, "h":40, "bind":"level", "min":0, "max":100, "step":1, "emit":"level.change" }
ctx.on('level.change', async ({context}) => { state.level = context.value; await ctx.publish(state); return {ok:true}; });
Event schema: { "context": { "type":"object", "required":["id","value","context"], "additionalProperties":false, "properties": { "id":{"type":"string"}, "value":{"type":"number","minimum":0,"maximum":100}, "context":{"type":"null"} } }, "write":false, "permissions":[] }. This changes plugin state only. Physical volume uses ctx.hid.consumer, not an invented absolute-volume API.
{ "context": { "type":"object", "required":["id","value","context"], "additionalProperties":false, "properties": { "id":{"type":"string"}, "value":{"type":"number","minimum":0,"maximum":100}, "context":{"type":"null"} } }, "write":false, "permissions":[] }
ctx.hid.consumer
Combined primitives, hits, widgets and options are limited to 256 nodes; each dynamic options source reserves its maximum 64 options. Widget nesting is limited to 8 levels, also subject to existing overall JSON byte/depth limits. Devices advertise widgets.v1, system.icons.v1, system.fonts.v1, image.animation.v1, media.fullscreen.v1. Packages needing unavailable features are incompatible on older firmware; legacy packages continue working.
widgets.v1
system.icons.v1
system.fonts.v1
image.animation.v1
media.fullscreen.v1
Images accept animation: {type:"fade"|"slide"|"zoom", duration:1000, delay:0, repeat:false, easing:"linear"|"easeInOut", from?, to?, axis?:"x"|"y"}. Duration: 100-10000 ms; delay: 0-60000 ms. Fade uses opacity 0-1 (default 0 to 1), slide uses pixel offsets -854 to 854 (default -32 to 0 on x), zoom uses scale 0.25-4 (default 0.85 to 1). At most eight animations. They restart when a photo source changes, clip to the viewport, and do not restart on continuous video frames. GIF/APNG are not projection assets; decode authorized GIF bytes on the host and publish frames through ctx.frames.
animation: {type:"fade"|"slide"|"zoom", duration:1000, delay:0, repeat:false, easing:"linear"|"easeInOut", from?, to?, axis?:"x"|"y"}
ctx.frames
fullscreen: true is valid only for images, including the single streaming image used for video. Tapping opens an 854x480 black surface with only the media, fitted without cropping by default. Set fullscreenFit: "cover" alongside fullscreen: true to fill the whole screen with centered cropping and preserved aspect ratio; "contain" keeps the complete image. Any explicit fullscreenFit value (including contain) requires media.fullscreen.cover.v1; omit the field for older firmware. Update the Desktop host and device App when older firmware reports incompatible. Normal viewport layout and stream dimensions do not change. Every tap exits, including the former header area, without emitting a plugin event. Text, controls, containers and custom navigation cannot enter fullscreen; do not build a fullscreen container, hide the system header yourself or overlay controls. It resets on plugin closure/disconnection.
fullscreen: true
fullscreenFit: "cover"
"contain"
fullscreenFit
contain
media.fullscreen.cover.v1
Streaming photos with a non-repeating fade from 0 to 1 crossfade the previous and replacement images in both normal and fullscreen views. Keep calling images.show directly for the next photo: loading and transfer retain the current image, and the old frame is released when the transition finishes. Do not insert images.clear between slideshow photos.
images.show
images.clear
ctx.images.show(source, {animate:false}) disables the animation for this photo replacement, including fullscreen. animate:true or omitting the option uses the stream primitive's configured animation; it does not create an animation if none is declared. The choice is per call and does not change the projection or subsequent calls. Keep the current photo until the replacement is ready. Use animate:false for rapid navigation and animate:true for deliberate slideshow transitions. Updated previews and SDL support this flag; physical devices require firmware advertising imageStream.animationControl:true. Older firmware may ignore the flag and retain the configured animation.
ctx.images.show(source, {animate:false})
animate:true
animate:false
imageStream.animationControl:true
After showing a photo, call ctx.images.prefetch([previousSource, nextSource], {fit, signal}) to predecode up to two adjacent photos without displaying or transferring them. Use the same fit as show. The host retains at most three decoded photos, rechecks authorization and file identity/size/modification times before reuse, and cancels obsolete speculative work when a new show arrives. Catch prefetch failures separately from visible-photo failures. Clear, rescan, selection changes and runtime stop invalidate the cache. Do not await prefetch on the navigation path or prefetch an entire album.
ctx.images.prefetch([previousSource, nextSource], {fit, signal})
show
Use one image primitive with stream: true, an ordinary PNG fallback in asset, and integer w/h inside the 854x408 content area. Do not combine stream with assetBind or variants. Declare images.read and its permission on photo events. ctx.images.choose() opens a native picker only in live mode, returning opaque {id,name}[]; invoke it in a user event, never activation. Keep the IDs in runtime memory, not persisted settings/store. Selection is limited to 200 PNG/JPEG files, 20 MiB and 32 million pixels each. Canceled selection returns []; retain the prior album when canceled.
stream: true
stream
{id,name}[]
[]
Call await ctx.images.show({id: photos[index].id}, {fit:'contain', signal}) when navigating; cover crops to fill. A newer show supersedes unfinished work. ctx.images.clear() clears the current photo. Photos stay on the computer and the device uses a separate RAM-only stream, not package assets or JSON state. Do not publish photo bytes/data URLs or save photos to store. Captions, index and controls remain normal state. In fixture mode choose returns []; call ctx.images.show({asset:'assets/sample.png'}) with an explicitly packaged sample so both Preview and SDL exercise the image renderer. This API prepares a host frame; it does not promise that a disconnected device displayed it. Physical display requires firmware advertising the volatile imageStream capability and a connected running client.
await ctx.images.show({id: photos[index].id}, {fit:'contain', signal})
cover
ctx.images.clear()
ctx.images.show({asset:'assets/sample.png'})
imageStream
For persistent albums, declare host.imageFolders: [{id:'album',label:'Photo folder'}] in plugin.json (up to four) and images.read. This adds the native folder picker to the plugin's Settings. Do not simulate it with a folder-path text field or ask for arbitrary filesystem access. The client saves a read-only grant for the installation, restores it after restart and restarts a running runtime on folder changes. Removing a binding or revoking permissions retires its access. Paths and bookmarks are host-only; no store permission is needed to remember the folder.
host.imageFolders: [{id:'album',label:'Photo folder'}]
Merge this fragment into the existing plugin.json, keeping its other host fields and permissions:
{ "host": { "entry": "host.mjs", "sdk": "1.0.0", "permissions": ["images.read"], "imageFolders": [{ "id": "album", "label": "Photo folder" }] } }
There is no folder-picker field in config.schema.json. The client shows the picker in the installed plugin's Settings from host.imageFolders. Use ordinary configuration fields for slideshow preferences, for example:
config.schema.json
{ "type": "object", "properties": { "includeSubfolders": { "type": "boolean", "default": false }, "intervalSeconds": { "type": "integer", "minimum": 5, "default": 5 } }, "additionalProperties": false }
Read all pages before starting a slideshow:
async function readAlbum(ctx) { const photos = []; let cursor; do { const page = await ctx.images.list('album', { recursive: ctx.config.includeSubfolders === true, limit: 200, ...(cursor ? { cursor } : {}), signal: ctx.signal, }); photos.push(...page.photos); cursor = page.nextCursor; } while (cursor); return photos; }
In live activation call ctx.images.list('album', {recursive: ctx.config.includeSubfolders === true, limit:200, signal:ctx.signal}). It returns {photos:[{id,name}],nextCursor?}; follow cursors with the same recursive flag until absent. show({id}) accepts these IDs. No cursor starts a new scan and retires old IDs; refresh between slideshow cycles to include added files. Keep snapshots and IDs in runtime memory. Handle no grant (permission_denied), missing/inaccessible/replaced directory (unavailable), empty list and invalid photos gracefully. A moved folder requires reselection. Scans skip hidden entries/symlinks and only include nonempty PNG/JPEG files up to 20 MiB, with limits of 5,000 photos/20,000 examined entries and 16 directory levels; exceeding them fails with quota_exceeded. Fixture list returns an empty page and never reads saved grants; use packaged sample images. Use a serialized timer loop for autoplay, normally at least five seconds between slides, pause it while ctx.view.visible is false, and clear timers when ctx.signal aborts. Persist the current position (not photo IDs) with ctx.store if a restart should resume there. Refresh the list before reusing IDs after restart.
ctx.images.list('album', {recursive: ctx.config.includeSubfolders === true, limit:200, signal:ctx.signal})
{photos:[{id,name}],nextCursor?}
show({id})
quota_exceeded
Large plugin data uses the host's prefer-bulk policy: negotiated USB Bulk OUT first, CDC fallback when unavailable. Use ctx.images.show for albums, ctx.video for local video, ctx.frames.present/submit for generated or network-decoded frames, and packaged PNG assets for static artwork. No new permission, manifest field or per-call USB option is required. Do not implement direct endpoint access or encode images in JSON state/store. Controls, leases and acknowledgements remain on CDC. Keep SDK size, frame-rate, cancellation and backpressure limits; bulk is not a guaranteed frame rate. Failed writes are not replayed automatically.
prefer-bulk
ctx.images.show
ctx.video
ctx.frames.present/submit
With bulkTransport.imageFrameMaxBytes, photos use one binary BGRA frame, without Base64 or per-chunk acknowledgements; one CDC reply confirms validation and commit. Old firmware and unavailable Bulk retain the compatible chunked transport. Preview encodings are generated only when a preview requests them.
bulkTransport.imageFrameMaxBytes
The Agent service's installed plugin summaries (plugin_list, plugin_runtime status) include dataTransfer: {preference:'prefer-bulk', channel:'bulk-out'|'cdc'|'offline', maxFrame, bulkBytesSent}. bulkBytesSent measures the current shared physical link, not one plugin or confirmed display, and resets when that bulk connection changes. A preview does not claim physical bulk support. Verify a live album by opening it, advancing photos, checking the screen and observing the bulk counter increase. Preserve the current photo through loading/transfer; never clear between slides.
plugin_list
plugin_runtime status
dataTransfer: {preference:'prefer-bulk', channel:'bulk-out'|'cdc'|'offline', maxFrame, bulkBytesSent}
bulkBytesSent
Native SDL is an additional renderer check. Existing source-build mode depends on a firmware checkout and native libraries; it is not a prerequisite for the in-app Dock preview or the skill's sandbox validation. Report SDL or physical-device verification only when it actually ran.
Declare video.read in host.permissions and in write events that operate playback. Add one image primitive with stream: true, a PNG fallback asset and dimensions inside the content area. ctx.video.choose() returns {id,name}[] from the native local file picker; paths are never exposed. ctx.video.play({id}, {fit:'contain',loop:false,fps:10,signal}) decodes on the computer and streams MJPEG to the Dock. fit may be cover; fps must be an integer 1-55 and is a requested ceiling. Firmware limits still apply. Keep controls outside the video rectangle for direct video-layer acceleration; overlapping content uses software composition. Use pause(), resume(), seek(seconds), stop() and status(); status returns {state,position,duration,error?} with state stopped/playing/paused/ended/error. Pausing retains the current frame; stopping restores the fallback. The source file stays on the computer. Playback is silent, with no URL, RTSP/HLS, audio or DRM support. Selection IDs live only in the current runtime and must not be persisted. Firmware must advertise videoStream; V821 supports JPEG/MJPEG decoding, not H.264/H.265 decoding. For ctx.config.fixture, render sample state and do not choose or play local files. Do not claim guaranteed 55 fps, H.264/H.265 device decoding, audio or RTSP/HLS support. The bundled video-player is a complete example.
video.read
ctx.video.choose()
ctx.video.play({id}, {fit:'contain',loop:false,fps:10,signal})
fit
pause()
resume()
seek(seconds)
stop()
status()
{state,position,duration,error?}
videoStream
ctx.config.fixture
video-player
Use the same single stream: true image primitive for arbitrary rendered content. await ctx.frames.start({format:'mjpeg',fps:30}) returns {id,width,height,format,fps,maxBytes}. ctx.frames.createCanvas() creates a viewport-sized OffscreenCanvas; regular HTML Canvas also works. Draw with the standard 2D context and call await ctx.frames.present(canvas,{signal}). The client encodes and publishes the frame for preview, SDL and the Dock. No Canvas or JavaScript runs in firmware.
await ctx.frames.start({format:'mjpeg',fps:30})
{id,width,height,format,fps,maxBytes}
ctx.frames.createCanvas()
await ctx.frames.present(canvas,{signal})
For pre-encoded data call await ctx.frames.submit(bytes,{signal}), with a Uint8Array or ArrayBuffer. In bgra8888 mode it is tightly packed top-to-bottom B/G/R/A, straight alpha, exactly widthheight4 bytes; the host composites onto black. In mjpeg mode it is one complete JPEG of matching dimensions, at most 32768 bytes. It is not JSON/base64 state, multipart MJPEG, a GIF file or an MP4 container. Width/height are fixed by the projection. start supports fps 1-50; BGRA is capped at 10 and connected firmware may lower the rate. present adjusts JPEG quality to fit. Neither the requested ceiling nor a successful submission guarantees that the device displayed that frame.
await ctx.frames.submit(bytes,{signal})
Uint8Array
ArrayBuffer
bgra8888
mjpeg
start
The submission result is {sequence,accepted}: false means a newer waiting frame replaced this one; true means the host prepared it. Only one frame is encoded and one newest frame waits. The device completes its current transfer and then takes the latest frame. Always await submission; do not start an unbounded interval or stuff pixels into ctx.publish. While the plugin is hidden the awaited submission waits until it is shown again (see Runtime Lifecycle); stop decoding or fetching new source frames while ctx.view.visible is false instead of queuing work. status() returns {stream,submitted,dropped}. stop() cancels and clears. Stop timers on abort, and await any in-flight render before starting a new drawing loop. Images, local video and custom frames share the viewport; starting one replaces the others.
{sequence,accepted}
ctx.publish
{stream,submitted,dropped}
All 4 system fonts load during frames.start. Prefer g.font = ctx.frames.font('system-medium',24) and ctx.frames.drawIcon(g,'volume',x,y,24) over imported fonts or rasterized UI icons. The icon helper uses the current fill style and a text baseline at y. createCanvas starts with the system font; font helper sizes remain 8-48.
frames.start
g.font = ctx.frames.font('system-medium',24)
ctx.frames.drawIcon(g,'volume',x,y,24)
createCanvas
const {width,height} = await ctx.frames.start({format:'mjpeg',fps:30}); const canvas = ctx.frames.createCanvas(); const g = canvas.getContext('2d'); g.fillStyle = '#101713'; g.fillRect(0,0,width,height); g.strokeStyle = '#53d99a'; g.beginPath(); g.moveTo(16,height/2); g.lineTo(width-16,32); g.stroke(); g.font = ctx.frames.font('system',18); g.fillStyle = '#ffffff'; g.fillText('Signal',16,24); await ctx.frames.present(canvas,{signal:ctx.signal});
Drawing needs no permission. Network sources still require network: ctx.http.readBytes({path,credential?,signal?}) performs a bounded GET at the configured origin and returns {status,bytes,contentType}. It shares existing origin restrictions, redirect denial, credentials, 5-second timeout and 1 MiB response cap. Decode images with createImageBitmap(new Blob([bytes],{type:contentType})), draw them and close the bitmap. Chromium ImageDecoder can decode authorized GIF bytes frame by frame; close decoded VideoFrames and the decoder. Package assets remain PNG; small fixture bytes can be provided by a package-local ESM module.
{status,bytes,contentType}
createImageBitmap(new Blob([bytes],{type:contentType}))
ImageDecoder
Camera snapshot endpoints need an authorized HTTPS origin or localhost gateway. This supports rendering supplied frames; it does not grant camera/microphone capture, arbitrary fetch, direct RTSP/HLS or multipart-MJPEG ingestion. Do not claim those sources work without a gateway. apps/desktop/examples/sdk-showcase demonstrates every current widget, fonts/icons, animations, Canvas/raw BGRA/raw JPEG/GIF, photos/video and all current broker capabilities with isolated sample behavior.
apps/desktop/examples/sdk-showcase
Camera snapshot sources: refresh metadata independently from frame polling with at most one pending request; on transient errors keep the same source's last good frame for a bounded grace period, show buffering with its original timestamp and retry with capped backoff; clear frames on confirmed offline/access denial, source change or expiry. Gateway timeouts, player-error recovery and real-source tests are in the "Cameras and snapshot streams" section of the plugin reliability guide (plugin-optimization.md in the repository's docs/ or the tools distribution's dist/context).
docs/
dist/context
Continuous image/video/Canvas streams require an updated Desktop host that releases completed native USB transfers. Verify stable host memory during extended live playback and after stopping. Fixture checks and short previews cannot establish endurance; no new plugin permission, API call or automatic restart loop is required.
Use a package-local browser ESM engine; no CDN, eval, worker, external runtime download or device JavaScript is available. frames.createCanvas() owns a 2D context; create a separate WebGL canvas, render the scene and copy to the SDK canvas before await frames.present(). A custom JPEG encoder may instead await frames.submit(bytes,{signal}), respecting actual dimensions and maxBytes from frames.start. Keep a single serialized loop, bounded geometry and particles, and cancellable initialization. Dispose scenes, textures, engines and GPU contexts after pending work settles. Verify WebGL context loss/recovery and long-running memory in the real sandbox; a fixture image is not a physical frame-rate guarantee.
frames.createCanvas()
await frames.present()
await frames.submit(bytes,{signal})
maxBytes
Use ctx.fs.watchExternal(options, listener) with fs.watch. host.externalStorage optionally declares {scope: 'external', recursive: true}; omitted fields have these defaults. Users authorize current and future external storage in Permissions. This requires the updated Desktop App and a running host plugin; it cannot run as device-native logic. The runtime, and therefore the subscription, only exists while the plugin is open on the Dock or simulator and during the idle window; each restart subscribes again and starts from fresh initial events. Supported live discovery: macOS external physical volumes and Windows USB storage with drive letters. No Linux or Windows non-USB discovery yet.
ctx.fs.watchExternal(options, listener)
fs.watch
host.externalStorage
{scope: 'external', recursive: true}
Options: recursive may narrow the declaration; extensions (max 16, such as .json) and names (max 32 exact relative paths, case-sensitive) intersect for files; directories are included. debounceMs is 100..2000, default 250, plus optional signal and transfers (default false). No absolute paths, hidden paths, traversal or globs. Metadata is {path,kind:'file'|'directory',size:number|null,modifiedAt:number|null}; paths use /. fs.watch alone never exposes contents. No mutation APIs. ctx.fs.watch, manual directories, Dock-only scope and volume allowlists are not implemented.
recursive
extensions
.json
names
debounceMs
signal
transfers
{path,kind:'file'|'directory',size:number|null,modifiedAt:number|null}
/
ctx.fs.watch
The Promise resolves before callbacks begin. volume events carry action: mounted|unmounted, volume:{volumeId,name}, and a per-mount mountId. volume-event carries volumeId/mountId and a nested event: reset:{epoch,reason}, changes:{epoch,fromRevision,revision,observedAt,changes}, or status:{status:'unavailable',reason}. A change is {type:'upsert',entry} or {type:'remove',path,kind}. Unmount invalidates that mount without individual removals. Remount creates a new mountId. Reset reasons are initial/overflow/reconnect; unavailable reasons root_missing/access_denied/quota_exceeded/backend_error.
action: mounted|unmounted
volume:{volumeId,name}
mountId
volume-event
reset:{epoch,reason}
changes:{epoch,fromRevision,revision,observedAt,changes}
status:{status:'unavailable',reason}
{type:'upsert',entry}
{type:'remove',path,kind}
On reset, call watcher.snapshot(volumeId,mountId,{cursor?,limit?,signal?}), page through {epoch,revision,entries,nextCursor?}, then apply only later same-epoch changes with matching fromRevision. Discard older revisions; resnapshot on gaps. Pages freeze one index version, up to 200 entries/48 KiB each; cursors are one-use, expire after 60s and are invalidated by a new snapshot or epoch. stale_event means resynchronize; unavailable means await recovery. Pace pages by 20ms to respect the shared SDK request limit. Remove directories and their descendants together. Changes describe final state, not an audit log or a guarantee of completed writes.
watcher.snapshot(volumeId,mountId,{cursor?,limit?,signal?})
{epoch,revision,entries,nextCursor?}
stale_event
Callbacks run serially; throwing or exceeding 10s closes the subscription. Always observe watcher.closed.catch(error => ctx.log.warn(String(error))); discovery/transport/consumer errors reject it. Do not await closed during activation. Return cleanup calling watcher.close() (idempotent), which suppresses new callbacks; entered callbacks cannot be forcibly canceled. Abort/stop/revoke release resources. At most one collection per plugin, eight volumes, 64 collections per App; discovery failure or too many volumes rejects closed. Each root is bounded to 20,000 scanned entries, 1,024 directories, depth 16, 8s and an estimated 8 MiB index. Global committed indices: 100,000 entries/32 MiB. Queue: 256 events/128 KiB, overflow resets epochs; batches: 128 changes/48 KiB and four events/second. Exclude hidden paths, links/junctions and cross-filesystem submounts. Inaccessible roots retry every 60s.
watcher.closed.catch(error => ctx.log.warn(String(error)))
watcher.close()
Files use native directory notifications plus a 60s reconciliation scan; volume discovery reconciles every 5s. Sandboxed clients wait on event RPC, not a polling timer. CLI, standalone MCP and App previews expose a virtual external volume containing Documents, status.json, and Documents/report.csv, honoring filters and recursion without touching real disks. Preview does not prove live OS access. Read the exported Fs types and FILESYSTEM_WATCH_CAPABILITY for the current contract.
Documents
status.json
Documents/report.csv
Declare both fs.watch and fs.transfers, obtain user consent, and opt in with watchExternal({transfers:true}, listener). macOS observes public NSProgress published by Finder/cooperating applications; Windows reports unsupported. Coverage is always published-only, never universal copy monitoring. Subscription covers validated discovered directories; newly created, short-lived or over-quota directories/tasks may be missed. No events does not prove no transfers. Changes in file size or quiet time are not progress/completion evidence.
fs.transfers
watchExternal({transfers:true}, listener)
published-only
Events are {type:'transfer',volumeId,mountId,transfer}, {type:'transfer-status',volumeId,mountId,status}, and {type:'transfer-reset',volumeId,mountId}. A transfer has id,path,source:'macos-nsprogress'|'fixture',operation:'copy'|'move'|'download'|'upload'|'other',state:'running'|'paused'|'completed'|'cancelled'|'unknown',completedUnits,totalUnits,fractionCompleted,bytesPerSecond,estimatedSecondsRemaining,observedAt. Numeric fields except observedAt may be null. Units belong to the publisher and are NOT necessarily bytes; fraction is a publisher-reported 0..1 value. Rates/ETA only appear when explicitly published. Completed requires publisher finished, cancelled takes precedence, and unpublication/process loss without finished is unknown. Completed is not a checksum, durability or safe-ejection guarantee. Folder tasks need not publish per-file completion.
{type:'transfer',volumeId,mountId,transfer}
{type:'transfer-status',volumeId,mountId,status}
{type:'transfer-reset',volumeId,mountId}
id,path,source:'macos-nsprogress'|'fixture',operation:'copy'|'move'|'download'|'upload'|'other',state:'running'|'paused'|'completed'|'cancelled'|'unknown',completedUnits,totalUnits,fractionCompleted,bytesPerSecond,estimatedSecondsRemaining,observedAt
Status is {state:'starting'|'available'|'unsupported'|'unavailable',coverage:'published-only',reason?}; reasons are platform/helper_missing/backend_error/quota_exceeded/root_unavailable. Available means backend ready, not exhaustive coverage. Backend failure marks active tasks unknown; resubscribe to restart. On transfer-reset call watcher.transferSnapshot(volumeId,mountId,{signal?}) for {status,transfers} and replace local state. At most 128 tasks per volume; terminal records last at most 60s and may be evicted earlier for capacity. Updates coalesce within the existing 4 events/sec budget, with native sampling every 250ms; this is not an audit log. Unmount invalidates the mount and its cache. CLI/MCP opt-in fixtures simulate a copy of status.json and completion.
{state:'starting'|'available'|'unsupported'|'unavailable',coverage:'published-only',reason?}
watcher.transferSnapshot(volumeId,mountId,{signal?})
{status,transfers}
Declare fs.watch and separate fs.read, with user consent. watcher.readBytes(volumeId,mountId,path,{maxBytes?,signal?}) returns {bytes:Uint8Array,entry:FsEntry} containing the entire file in one call; callers do not need to split or concatenate it. readText returns strict UTF-8; readJson parses and validates SDK JSON (depth 16/nodes 8192, no dangerous keys or non-finite numbers). Invalid text/JSON returns invalid_config. There is no SDK file-size cap; optional maxBytes is a positive safe integer chosen by the caller, with quota_exceeded if the entire file is larger. readChunks(volumeId,mountId,path,{chunkSize?,maxBytes?,signal?}) asynchronously yields {bytes,entry,offset,eof,version}; omitting chunkSize enables automatic 4–16 MiB blocks (grow below 250 ms per read, shrink above 1000 ms); an explicit chunkSize fixes the block size, from 1 byte to 16 MiB. These are IPC block sizes, not file-size limits. One verified file descriptor is reused per iterator. Use it for bounded-memory complete-file processing, in a cancellable background task rather than inside the 10-second event callback. Await processing before requesting another chunk. Reads share a rolling 64 requests/second budget across all watchers in a plugin; wait only when exhausted. Do not add per-chunk sleep. EOF, error, break, cancellation, stop and unmount release the retained handle; ejection waits for release. A stream idle for 30 seconds expires and subsequent reads fail stale_event. Process each block promptly or cancel and restart. Chunks are provisional: discard partial verification on error and mark success only after EOF. File versions are checked across chunks. Only visible relative regular files inside the active mount and subscription recursion/name/extension filters are allowed. Links, hardlinks, directories and cross-filesystem submounts are rejected. File identity/size/timestamps changing during reading fail stale_event; whole-file helpers discard partial results; streaming consumers must discard their partial verification on error. Reading does not guarantee another process finished writing. Old mounts fail stale_event; OS access failures are permission_denied/unavailable. Two reads per subscription and eight globally, with a 5s checked budget per chunk, not for the entire stream; underlying filesystem calls may return later. Stop/revoke/abort prevent results from being delivered. No writes or shell access. Network-authorized plugins can transmit read content to their authorized origin, disclosed in Permissions.
fs.read
watcher.readBytes(volumeId,mountId,path,{maxBytes?,signal?})
{bytes:Uint8Array,entry:FsEntry}
readText
readJson
Fixtures provide status.json as {"message":"Sample external storage","value":42} and Documents/report.csv as name,value\nsample,42\n. Real content access is never used by previews. Capability discovery adds filesystemWatch.transfers and .contentRead; metadataOnly:true still describes the fs.watch-only permission.
{"message":"Sample external storage","value":42}
name,value\nsample,42\n
filesystemWatch.transfers
.contentRead
metadataOnly:true
Declare fs.watch and separate fs.eject, with explicit user authorization. watcher.eject(volumeId,mountId,{signal?}) safely removes the whole physical storage device, including its other mounted partitions. File filters/recursion do not narrow physical removal. Prefer a user control event marked write:true with permissions:['fs.eject']; do not eject automatically because one transfer completed. No force option, arbitrary path/device ID, elevation, process termination or file writes are exposed.
fs.eject
watcher.eject(volumeId,mountId,{signal?})
write:true
permissions:['fs.eject']
Result: {status:'ejected',simulated:boolean}; {status:'failed',reason:'busy'|'permission_denied'|'unsupported'}; or {status:'unknown',reason:'disconnected'|'timeout'|'backend_error'}. Only ejected confirms OS success; unmounted alone could mean physical unplugging. The host revalidates volume identity/mount generation and resolves one physical disk, pausing this filesystem subsystem across plugins on that device before removal. At most one live request per App, no queue/retries; concurrent requests get overloaded. macOS uses non-forced diskutil eject; ambiguous multi-device APFS targets are unsupported. Windows USB storage uses CM_Request_Device_EjectW and respects vetoes; system/boot disks are excluded. Windows hardware validation remains required.
{status:'ejected',simulated:boolean}
{status:'failed',reason:'busy'|'permission_denied'|'unsupported'}
{status:'unknown',reason:'disconnected'|'timeout'|'backend_error'}
OS commands time out after 30s plus bounded discovery and up to 5s of handle draining. Stop/revoke/abort before submission prevents the request; after submission cancellation cannot undo it and may surface stopped/unknown. Never automatically retry unknown outcomes. Success emits unmounted and invalidates old mount IDs across plugin subscriptions; failure rebuilds metadata via reset and resumes watching. Other apps or image/video readers can still cause busy. Preview ejection returns simulated:true, emits unmounted and invalidates the virtual mount without OS commands. filesystemWatch.eject describes permission/platforms/physical-device scope/force:false/fixture preview. No standalone CLI/MCP command directly ejects a developer's real disk.
filesystemWatch.eject
Declare fs.watch, then call watcher.subscribeIo(volumeId, mountId, listener, { signal?, onError? }). It returns an unsubscribe function. Obtain target IDs from volume/mounted events; do not pass disk names, absolute paths or OS disk numbers. Matching names do not establish volume identity. Use the new mountId after remounting.
watcher.subscribeIo(volumeId, mountId, listener, { signal?, onError? })
volume/mounted
let watcher; watcher = await ctx.fs.watchExternal({ fileChanges: false, signal: ctx.signal }, event => { if (event.type !== 'volume' || event.action !== 'mounted') return; watcher.subscribeIo(event.volume.volumeId, event.mountId, sample => { // Update local state and use the shared coalescing publisher (at least 300 ms between starts). // Read both rates when sample.status === 'ready'; show an em dash for unavailable, never zero. updateIoState(sample); }, { signal: ctx.signal, onError: error => ctx.log.warn(String(error)) }); }); void watcher.closed.catch(error => ctx.log.warn(String(error))); // Await watcher.close() on shutdown, or call the returned function to unsubscribe individually.
FsIoSample includes volumeId, mountId, status, scope, readBytesPerSecond, writeBytesPerSecond, sampledAt, intervalMs and simulated. Ready rates are nonnegative bytes/s: divide by 1,000,000 for MB/s or 1,048,576 for MiB/s. Idle is zero. Unavailable rates, scope, timestamp and interval are null; reason distinguishes warming_up, unsupported, stale, backend_error, root_unavailable and unmounted. Transport, permission or consumer failures invoke onError and terminate that subscription. Temporary unavailability keeps the subscription alive for recovery. Unplugging ends subscriptions for that mount; subscribe again after the next mounted event.
FsIoSample
Shared OS counters are sampled about every two seconds; intervalMs is the actual difference window and sampledAt is the sample time. Cached samples older than ten seconds are stale. The first measurement establishes a baseline; cold startup normally takes 4–6 seconds for a valid sample, and existing baselines can be faster. macOS prefers APFS volume counters and falls back to the physical disk when volume counters are absent, such as ExFAT. Windows prefers LogicalDisk and falls back to PhysicalDisk, with a one-second counter window and SDK updates about every two seconds, subject to performance counter availability. A physical-device scope includes other partitions on the same disk; never sum these samples across partitions. Disks that cannot be mapped safely report unavailable.
This is window-averaged actual system I/O, including other applications. It is not rated bandwidth, a benchmark, SDK file-read speed, a Finder task rate or transfer completion. Cached file reads may produce no disk I/O. No fs.read or fs.transfers permission is required; the API neither reads file contents nor writes benchmark files. Name and extension filters do not filter volume or physical-disk counters.
The SDK reuses one timer per watcher and shares non-overlapping requests for the same target, with at most eight targets and 32 listeners. Busy asynchronous listeners skip intermediate samples; exceeding ten seconds terminates that listener with consumer_error without affecting others. Unsubscribe, signal cancellation, watcher.close and plugin stop clean up subscriptions. The host releases idle sampling leases within 6.5 seconds of the last query, or immediately on watcher close or unplug. Desktop must run; a USB/Dock connection is not required. An offline device cannot invoke this host API independently.
The CLI, both MCP servers, SDK types and developer context share the filesystemWatch.io contract. Previews return simulated=true with fixed read/write rates of 24 MB/s and 8 MB/s. They do not measure hardware. Older Apps lack subscribeIo; prompt users to update. This capability adds no CLI benchmark or real disk-writing command.
Plugins that only need volume lifecycle, I/O or published transfers should use ctx.fs.watchExternal({ fileChanges: false, transfers: true }, listener); omit transfers when unnecessary. fileChanges defaults to true for compatibility. With false, the host does not traverse directories, build file indexes, create native file watchers or emit volume-event, and snapshot() returns unsupported. Volume discovery/lifecycle, subscribeIo, transferSnapshot, readChunks/readBytes/readText/readJson for known relative paths, and eject remain available with unchanged permissions. recursive still constrains authorized read/transfer paths; recursive:false does not disable indexing.
ctx.fs.watchExternal({ fileChanges: false, transfers: true }, listener)
Without indexing, macOS transfer observation subscribes only at mount roots. It no longer discovers subdirectory NSProgress subscriptions by scanning, so some subdirectory-published tasks may be absent; this is not whole-disk transfer coverage. The SDK, CLI and both MCPs advertise filesystemWatch.fileChanges; previews also omit indexes and reset events. I/O-only plugins should not display indexing state or scan the entire disk themselves; do not hide index errors while leaving indexing enabled. Older Apps must be updated; do not silently fall back to recursive scanning.
macOS associates physical disks, APFSPhysicalStores and APFSVolumes from disk listings without the slower diskutil apfs list or file scans. After a transient OS error or timeout, the watcher stays open and retries discovery every five seconds without overlapping calls. It emits {type:'discovery-status',status:'retrying',reason:'backend_error'}, then {type:'discovery-status',status:'available'} on recovery. Do not interpret failure as an empty volume list, reopen the watcher or add scanning/retry timers.
diskutil apfs list
{type:'discovery-status',status:'retrying',reason:'backend_error'}
{type:'discovery-status',status:'available'}
During failure, retain known volume names, but file reads, snapshots, I/O queries and ejection are temporarily unavailable. Cancel active reads, release I/O leases and mark unfinished transfers unknown. Show Retrying, clear stale rates and stop pending verification. Recovery preserves IDs for unchanged mounts and resumes existing I/O subscriptions. Disappeared or replaced mounts emit unmounted/mounted and require the new mountId. Only unrecoverable permission/quota failures or explicit close end the watcher; still handle watcher.closed. Close, stop and permission revocation cancel further retries.
Projection pages declares 1-16 unique {id,left?,right?,up?,down?} entries. Direction names describe the finger; targets must refer to another declared page. initialPage defaults to the first page. Optional page on primitives, hits and root widgets selects a page; omitted means a shared stationary node. Children inherit their root's page. IDs are ASCII letter plus letters/digits/underscore/hyphen, maximum 64 characters. Node, animation, asset and size budgets remain package-wide. The system header stays fixed.
pages
{id,left?,right?,up?,down?}
initialPage
page
The Dock owns gesture recognition, current page, following the finger, 48px commit threshold (dominant axis ratio 1.25) and 180ms settle/cancel animation. Sliders/scrolling controls own input; dialogs and fullscreen media suspend navigation. Swipes suppress release taps. State refreshes preserve the selected page; a new view starts at the initial page. Firmware must advertise pages.swipe.v1; older devices are incompatible. Native primitive/data-source pages need no Host. Pages do not enable offline scripts, Host bindings or widgets.
pages.swipe.v1
Optional pageChange names a declared write:false event with no permissions and context {from,to,direction}. The context schema must accept every declared edge. A committed local transition notifies the connected Host without waiting for its response. This option requires Host execution and cannot coexist with device sources. No initial/failed/cancelled notification; view-scoped, bounded notifications may be dropped and are never replayed. It is observational, not authorization to perform external writes or a way to drive page selection. Ordinary control contexts stay unchanged. See the page guide and page-deck example.
pageChange
write:false
{from,to,direction}
page-deck
Install on device persists the entry and icon independently of host Enable. A connected Dock advertising residentCatalog.version: 1 is required to install/uninstall. A catalog entry is available when the plugin is enabled and its permissions, settings, secrets and platform requirements are satisfied; opening it starts the host runtime on demand (up to the Dock's 30-second activation wait). A disabled, faulted or otherwise unstartable host plugin shows a cancellable wait dialog that closes automatically when the entry becomes available again. Stop, revoke, host disconnection and Dock restart preserve entries. Native-only projections run immediately offline after installation. Leaving a plugin on the Dock (Back or switching) releases its device view, decoder, animation and image resources and cancels pending projection transfers and view events; the host runtime becomes hidden and stops after the idle window, and reopening creates a new view.
residentCatalog.version: 1
Use SDK deviceExecution(manifest, projection) to inspect the mode. native requires no host permissions, knob actions, hits, widgets, pageChange notifications or streams. Local pages and directional navigation are allowed. Bindings (bind, selected, assetBind) are allowed only under a declared sources.<id>; other state bindings require the host. Static text/shapes/icons/packaged PNGs, native image animations and fullscreen viewing are supported. Firmware stores actual PNG files plus the projection. Games, timers, slideshow selection, Canvas and all host SDK behavior remain host; no device JavaScript or cached fixture execution is provided. Do not remove bindings/permissions merely to claim offline support. The negotiated resident storage budget is 2 MiB including metadata, images and 64 KiB reserved per data-source plugin for cache.
deviceExecution(manifest, projection)
native
sources.<id>
host
The Dock itself fetches JSON from LAN hosts and public HTTPS/WSS services, without a runtime host, the Desktop App or USB (updated firmware required). Sources are read-only and cannot be triggered by controls; compare them with ctx.http under Choose A Network Path. Declare 1-4 sources on device.projection.json:
device.projection.json
{ "sources": [ { "id": "weather", "kind": "http", "originConfig": "weatherOrigin", "path": "/now", "intervalMs": 60000, "maxBytes": 4096 }, { "id": "live", "kind": "websocket", "originConfig": "liveOrigin", "path": "/stream", "maxBytes": 4096 } ] }
Each originConfig names a string property in the configuration schema. Users choose an origin (for example https://data.example.com or wss://data.example.com), without credentials/path/query/fragment. Paths begin with a single /; percent-encode non-ASCII characters. HTTP is GET only. WebSocket accepts UTF-8 JSON text messages, supports fragmentation and ping/pong, and reconnects with backoff. It sends no application messages: authentication headers, subscription commands, Socket.IO and arbitrary binary messages are not supported.
originConfig
https://data.example.com
wss://data.example.com
Optional pathConfig names a string setting that replaces path. Optional queryConfig maps up to 16 query parameter names to top-level string, number/integer or boolean settings. All setting references and query names use the same identifier rules as originConfig. Referenced settings must exist; null, arrays, objects, non-finite numbers and invalid Unicode are rejected with invalid_config. Values are converted to strings and percent-encoded, then appended to the path's existing query (including empty strings, 0 and false). The final path must satisfy the same path rules and 2048-character limit. For example, "pathConfig":"requestPath","queryConfig":{"city":"city","units":"units"} with requestPath="/weather", city="New York" and units="metric" requests /weather?city=New%20York&units=metric. Both HTTP and WebSocket handshake URLs support these settings.
pathConfig
queryConfig
invalid_config
0
false
"pathConfig":"requestPath","queryConfig":{"city":"city","units":"units"}
requestPath="/weather"
city="New York"
units="metric"
/weather?city=New%20York&units=metric
The App compiles only the referenced settings into the installed native presentation; it does not copy the full configuration, host store or secrets. The Dock persists the resulting origin/path/query and uses them after USB disconnection and restart. Changing a referenced setting invalidates device consent and requires approval and catalog synchronization; edits made while disconnected take effect on the next connection. Parameter changes also change the presentation hash and isolate the response cache. This works with existing data-source firmware and adds no device JavaScript, custom headers or WebSocket application messages. Treat these as ordinary non-secret settings: request URLs and their values are stored on the Dock and visible to the target service. Host-held secret references cannot be used here. Older SDK/App/tooling must be updated to accept the new declaration fields.
HTTP requires intervalMs (5000-86400000) and waits this long after completion. WebSocket omits it and submits valid JSON immediately, coalescing pending updates to the latest value before rendering. Legacy WebSocket intervals are accepted but ignored. WebSocket requires the negotiated dataSources.websocketImmediate: true capability; minIntervalMs applies only to HTTP. Independent source workers prevent slow HTTP requests from blocking live sockets. Each source accepts at most maxBytes (1-65536), 512 JSON nodes and depth 16. The combined state and cache must fit 64 KiB. Responses replace state.sources[id]; bind text/progress/selected/packaged-image variants using existing point paths such as sources.weather.temperature. No expressions, scripts or downloaded artwork. Cache writes are coalesced to at most once per five seconds after the first write, with a final flush on network loss, view exit or normal shutdown; abrupt power loss can lose the latest unwritten updates.
intervalMs
dataSources.websocketImmediate: true
minIntervalMs
state.sources[id]
sources.weather.temperature
Use allowPrivateNetwork: true only when the service needs local-network access. This is an additional item in the App's consent screen. Private addresses are otherwise denied; plaintext http/ws requires this consent and an actual private destination. Public destinations require HTTPS/WSS with valid certificates. Redirects, proxies and third-party credentials are unavailable. Keep secrets out of the package and source URLs.
Every declared source needs a schema-valid sample at fixtures/default.json -> sources.<id>. Keep host.permissions: [], no secrets, no knob actions, and empty hits/widgets. A minimal preview activate(ctx) can publish fixture samples when ctx.config.fixture is true, and {sources:{}} otherwise if the state schema allows it. The device never uses fixture data as live data.
fixtures/default.json
host.permissions: []
activate(ctx)
{sources:{}}
The App resolves origins only after permission review and includes them in the native presentation and consent fingerprint. Changing the package or origins invalidates consent. Install on device and open it; no host runtime is started. Offline permission/configuration changes reach the Dock on the next App connection. Firmware advertises data.sources.v1 and the dataSources capability with supported kinds and budgets; older firmware must be updated. See apps/desktop/examples/device-data-card for a complete example.
data.sources.v1
dataSources
apps/desktop/examples/device-data-card
For an event declared with context: {"type":"null"}, invoke plugin_event with the actual JSON value null, never the string "null" or {}. An invalid_event caused by a mismatched test payload is not a reason to change the event schema or plugin behavior. Correct the call and recheck if needed.
context: {"type":"null"}
plugin_event
"null"
{}
invalid_event
Vibe supports Codex and the official local Claude Code CLI. Both use the same plugin tool schemas, validation, fixture previews, screenshots and installation pipeline. Claude may show an MCP prefix on tool names. Native provider authentication and authoring permissions are separate from installed-plugin permissions; plugins cannot read subscription credentials or invoke an AI provider through a new runtime API. External Desktop/standalone MCP contracts and CLI commands are unchanged.
The plugin runtime contract remains unchanged. Distribution metadata uses registry protocol version 1, with types exported from the SDK registry module. The App and website share the preview renderer and package validator. A submission ZIP (.boltsubmission) contains submission.json (format pexarbolt-submission/1, MIT license and runtime checksum), plugin.boltplugin, and editable source/. Platform submission requires author confirmation of MIT publication rights; local export does not publish. CLI submission and both MCP plugin_submission tools export these artifacts. CLI upload can upload a draft with an explicitly configured token. Source, screenshots of private data, secrets and user configuration must not be confused with distributable runtime state. Documentation: docs/plugin-publishing.md, bundled as dist/context/plugin-publishing.md.
.boltsubmission
submission.json
pexarbolt-submission/1
plugin.boltplugin
source/
submission
plugin_submission
docs/plugin-publishing.md
dist/context/plugin-publishing.md
Submission exporters also capture the checked fixture runtime state and optional PNG/JPEG media frame in submission.json.preview. The website validates the state schema and bounded frame format/dimensions, then uses the shared renderer. No manual screenshots are required. The snapshot is never taken from an authorized live installation. A submission without this optional metadata uses its default fixture.
submission.json.preview
ctx.backup requires Desktop App 0.3.0. Declare backup.read for summaries and backup.control for native setup and commands. This is an App-owned background service, separate from read-only ctx.fs; it does not grant arbitrary filesystem writes. Methods: snapshot(), subscribe(listener,onError?), configure(eventId), command({action,eventId,jobId?,ruleId?}). Actions: start, pause, resume, cancel, disable-auto, open-folder, report. Native configuration separately authorizes external source/target folders and optional insertion automation. Jobs continue when the plugin closes, but require the App. Read plugin-backup.md for grants, identity, limits, reports, pause/recovery and platform scope. CLI/MCP/web checks simulate jobs and perform no real copying.
ctx.backup
backup.read
backup.control
ctx.fs
plugin.json supports optional author: a non-empty display name of at most 80 characters, without control characters. Use the author supplied by the user; do not invent a person or organization. This is package attribution, not verified publisher identity. Existing packages without it remain valid. Studio shows the name on project cards and details; an omitted name is shown as unspecified.
author
Optional listing.json contains summary (short description, max 180 characters), description (max 16000) and changelog (release notes, max 8000). These are publishing metadata, never runtime permissions or verified ownership. Package attribution uses optional plugin.json.author (non-empty, max 80, no control characters). Removing attribution omits that property. Source-bearing .boltplugin exports retain this information; website upload imports it for user review. Studio metadata saves validate all four string fields, require the observed package revision, preserve unrelated listing fields and reject stale edits.
listing.json
summary
description
changelog
plugin.json.author
.boltplugin
An interactive sample preview retains its independent page/simulator lifetime when a real Dock starts the same installation. Fixture controls never operate the physical instance. Stop page previews explicitly when leaving their UI; live host visibility and idle shutdown remain separate. Source Tree reads verified installed package files only with an expected revision; it is read-only and omits private configuration, credentials and runtime stores. Native SDL displays the 854×480 canvas at 100% backing-pixel scale, including Retina screens; outer control chrome is separate from device coordinates.