Implemented on 2026-09-16 as use case B of the Dock device networking design (archived in the client repository at docs/design/2026-09-15-device-network.md), extended with direct WebSocket data services. The normative rules are in the plugin contract; this guide adds setup, network policy, lifecycle and verification details. The Dock renders the native projection without USB, a running Desktop App or a device scripting engine.
docs/design/2026-09-15-device-network.md
The Dock connects directly, over its own Wi-Fi, to LAN hosts (plain or TLS, after allowPrivateNetwork and the user's LAN grant) and to public HTTPS/WSS services with valid certificates. Sources are read-only: HTTP uses GET and WebSocket only receives, and cards with sources have no controls. To send commands or react to a button, use host-side ctx.http, which needs the Desktop App and reaches LAN hosts only over trusted HTTPS or through a localhost gateway; the contract's Choose A Network Path section compares both, and NETWORK_ACCESS_CAPABILITY describes them for tools.
allowPrivateNetwork
ctx.http
NETWORK_ACCESS_CAPABILITY
sources.<id>.<field>
The device-data-card example (apps/desktop/examples/device-data-card in the repository, dist/examples/device-data-card in the tools distribution) uses placeholder services; replace them with your own:
device-data-card
apps/desktop/examples/device-data-card
dist/examples/device-data-card
LAN services can use http://192.168.x.x:<port> or ws://192.168.x.x:<port> with LAN authorization. On the device, localhost means the device itself.
http://192.168.x.x:<port>
ws://192.168.x.x:<port>
{"sources":[ {"id":"weather","kind":"http","originConfig":"httpOrigin","path":"/weather","intervalMs":60000,"maxBytes":4096,"allowPrivateNetwork":true}, {"id":"live","kind":"websocket","originConfig":"websocketOrigin","path":"/stream","maxBytes":4096,"allowPrivateNetwork":true} ]}
dataSources: {version:1,kinds:['http','websocket'],maxSources:4,minIntervalMs:5000,maxBytes:65536,maxStateBytes:65536,websocketImmediate:true}
Declare the settings in config.schema.json:
config.schema.json
{ "type": "object", "additionalProperties": false, "properties": { "serviceOrigin": { "type": "string", "default": "https://data.example.com" }, "requestPath": { "type": "string", "default": "/weather" }, "city": { "type": "string", "default": "New York" }, "units": { "type": "string", "default": "metric" } }, "required": ["serviceOrigin", "requestPath", "city", "units"] }
Reference those settings from device.projection.json:
device.projection.json
{ "sources": [{ "id": "weather", "kind": "http", "originConfig": "serviceOrigin", "path": "/weather", "pathConfig": "requestPath", "queryConfig": { "city": "city", "units": "units" }, "intervalMs": 60000, "maxBytes": 4096 }] }
For a WebSocket source, use kind: "websocket", a wss:// origin and the service's connection path, and omit intervalMs. The same query settings apply to the WebSocket handshake URL.
kind: "websocket"
wss://
intervalMs
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
path
queryConfig
originConfig
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.
Public services require HTTPS/WSS with CA and hostname verification and no downgrade. Firmware maintains the CA bundle used by RuntimePaths.ca_file(). Requests use no environment proxy, netrc, credentials, custom headers or device identifiers, and never follow redirects.
CURLOPT_OPENSOCKETFUNCTION validates the actual IPv4/IPv6 destination before returning the socket, avoiding DNS rebinding between validation and connection. Private, loopback, link-local and IPv6 ULA addresses require explicit authorization. Unspecified, multicast and certain other non-unicast addresses are always denied. Even with LAN permission, plaintext requests cannot connect to public addresses.
Each source has one background worker, at most four total, so slow HTTP/TLS requests do not block WebSocket sources. Connected WebSockets check readable data every 20 ms and pass the latest value to rendering without an additional refresh interval. Actual display latency still depends on the network and renderer. Connection timeout is three seconds; request/connection budget is five seconds. Failures back off from five to 60 seconds with jitter. V821 fetches only while Wi-Fi is connected and dhcp_ready; it pauses offline and reconnects when available.
WebSocket uses curl TLS/raw I/O and a bounded RFC 6455 codec, without requiring a newer curl WebSocket API or new runtime dependencies. Only UTF-8 JSON text is accepted, including fragmentation and ping/pong. No compression/subprotocol negotiation or application messages are sent. Socket.IO, authentication headers, subscription commands and binary payloads are unsupported. A ping is sent every 20 seconds; missing the matching pong for ten seconds reconnects. Per-second intake is capped at max(65536,maxBytes*4) bytes and 128 data messages.
Opening a card loads cache before connecting. Leaving, switching, catalog removal/update or valid host takeover stops old sources. Session generations invalidate late results. USB disconnection does not stop native sources.
Successful payloads update memory immediately. The first cache is persisted immediately; later writes coalesce at most once every five seconds, with a final flush on network loss, card exit or normal shutdown. Sudden power loss can lose the latest five seconds. Atomic caches live at resident-plugins/<nativeHash>.sources.json, isolated by final presentation hash, so changed origins/packages cannot reuse another service's cache. Each source plugin reserves 64 KiB within the resident catalog's 2 MiB budget. Removal/revocation deletes old caches; stopping an old view cannot recreate a revoked cache.
resident-plugins/<nativeHash>.sources.json
System navigation shows update time, waiting, offline or errors. Codes include address_denied, tls_error, redirect_denied, timeout, http_error, response_too_large, invalid_json, websocket_handshake, websocket_protocol, state_too_large and cache_write_failed.
The separate device grant fingerprint includes package, source declarations, origins and referenced path/query settings. Referenced setting changes or upgrades require renewed authorization. Online edits/revocation synchronize the catalog and stop device sources; offline changes take effect at the next App connection. A disconnected client cannot immediately revoke grants already stored on the Dock.
pnpm --filter @pexarbolt/plugin-sdk build pnpm --filter @pexarbolt/plugin-sdk test pnpm --filter @pexarbolt/plugin-studio test -- electron/plugins/plugin-router.test.ts electron/plugins/plugin-package-store.test.ts electron/plugins/plugin-manager.test.ts pnpm --filter @pexarbolt/plugin-studio type-check node .agents/skills/pexarbolt-plugin/scripts/plugin.mjs validate apps/desktop/examples/device-data-card node apps/desktop/scripts/test-device-sources-sdl.mjs
Firmware runs make -C tests test-plugin-sources. Both repositories consume packages/protocol/test-vectors/network-policy.json; they are adjacent by default, or NETWORK_POLICY_VECTORS overrides the path. Tests cover real HTTP, WebSocket fragmentation/reconnect, HTTPS/WSS trust and hostname checks, LAN policy, oversized/malicious JSON, cancellation/late replies, updates without USB, offline cache, restart recovery and revocation cleanup.
make -C tests test-plugin-sources
packages/protocol/test-vectors/network-policy.json
For the existing json-c unused-function warning on macOS:
make -C tests test-plugin-sources CXXFLAGS='-std=c++17 -fno-exceptions -fno-rtti -Wall -Wextra -Werror -Wno-unused-function'
Host SDL can open a native card using BOLT_HOST_PLUGIN_ID=<installed-plugin-id> without a script or preview service. Build firmware Host SDL first. test-device-sources-sdl.mjs uses isolated storage and local HTTP/WS servers to verify online values and cache recovery after server shutdown. Screenshots go to .cache/device-sources/; BOLT_FIRMWARE_ROOT selects firmware source.
BOLT_HOST_PLUGIN_ID=<installed-plugin-id>
.cache/device-sources/
Projection.pages adds four-direction Dock-local navigation in Host-connected and independent native installations. Tag primitives, hits or root widgets with page; use initialPage for entry and optional read-only pageChange for Host notifications. Requires firmware advertising pages.swipe.v1; legacy projections remain valid. See local page navigation for the declaration, lifecycle, gesture priorities and examples.
Projection.pages
page
initialPage
pageChange
pages.swipe.v1