|
| 1 | +# Phase 7.0 — Capability-only WebSocket |
| 2 | + |
| 3 | +**Date:** 2026-05-10 |
| 4 | +**Status:** spec |
| 5 | +**Goal:** generalize `connect()` so a device can expose capabilities to *any* AI on the hub, without being paired with a specific publisher's key. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Why |
| 10 | + |
| 11 | +Today's `connect(ai_name, api_key, capabilities)` requires the device to know an AI's name and key in advance. That's right when there's a one-to-one relationship (Loki paired to ZAI), but it doesn't scale to "I have a generic device — any AI may use it": |
| 12 | + |
| 13 | +- A weather sensor wants to expose `weather_lookup` for every AI on the hub |
| 14 | +- A code-runner wants to expose `run_python` to anyone who can use it |
| 15 | +- A camera wants to expose `take_photo` once, not per-AI |
| 16 | + |
| 17 | +Capability-only WS turns devices into hub-level peripherals: register once, available to all. |
| 18 | + |
| 19 | +## Scope |
| 20 | + |
| 21 | +**In:** |
| 22 | +- New WS endpoint `/ws/expose` for unpaired devices |
| 23 | +- New envelope `register-exposure` (and helper) |
| 24 | +- Hub maintains an `exposures` registry separate from publishers/connections |
| 25 | +- Persistence: new `exposures` table mirroring `publishers` shape |
| 26 | +- HTTP `GET /exposures` — discovery |
| 27 | +- HTTP `POST /exposures/{exposure_id}/invoke` — invoke by any publisher key |
| 28 | +- Optional auto-injection: publisher's manifest sets `use_global_exposures: true` → hub adds global exposures to the `tools` it injects |
| 29 | + |
| 30 | +**Out:** |
| 31 | +- Per-AI access policies on exposures (every registered publisher can invoke for v1) |
| 32 | +- Capability streaming (long-running invokes) |
| 33 | +- Federation of exposures across peer hubs |
| 34 | +- Reverse direction: AI invoking exposure mid-stream during a chat (works via tool-call path; just no special syntax) |
| 35 | + |
| 36 | +## Architecture |
| 37 | + |
| 38 | +``` |
| 39 | +[device] [AI publisher] |
| 40 | + │ WS /ws/expose │ WS /ws/publish |
| 41 | + │ register-exposure │ |
| 42 | + ▼ ▼ |
| 43 | + ┌──────────────────────────── hub ───────────────────────┐ |
| 44 | + │ exposures: {exp_id → ExposureRegistration} │ |
| 45 | + │ publishers: {name → PublisherRegistration} │ |
| 46 | + │ connections_by_ai: {ai → {cid → ConnectionRegistration}}│ |
| 47 | + └────────────────────┬────────────────────────────────────┘ |
| 48 | + │ HTTP |
| 49 | + ┌──────────────┼──────────────┐ |
| 50 | + ▼ ▼ ▼ |
| 51 | + GET /exposures POST /exposures/<id>/invoke (chat-request path injects |
| 52 | + (discovery) (Bearer <publisher_key>) as tools when manifest opts in) |
| 53 | +``` |
| 54 | + |
| 55 | +A device's lifecycle: |
| 56 | +1. Open `WS /ws/expose` |
| 57 | +2. Send `register-exposure` with `{name, manifest, capabilities, public: bool, desired_key?}` |
| 58 | +3. Hub stores in `exposures`, sends back `registered` envelope with `{exposure_id, device_key}` (a `dx_...` token) |
| 59 | +4. Device idles, listens for `invoke-request` envelopes |
| 60 | +5. On invoke, runs handler, sends `invoke-result` |
| 61 | + |
| 62 | +Re-registration: same flow as publishers — pass back the `device_key`, hub recognizes it via SQLite, restores the exposure with the same `exposure_id`. |
| 63 | + |
| 64 | +## Wire envelopes |
| 65 | + |
| 66 | +New protocol additions (in `zhub/protocol.py`): |
| 67 | + |
| 68 | +```python |
| 69 | +def register_exposure(name: str, manifest: dict, device_key: Optional[str] = None) -> Envelope: |
| 70 | + return Envelope(type="register-exposure", |
| 71 | + payload={"name": name, "manifest": manifest, "device_key": device_key}) |
| 72 | + |
| 73 | +def exposure_registered(exposure_id: str, device_key: str, name: str) -> Envelope: |
| 74 | + return Envelope(type="exposure-registered", |
| 75 | + payload={"exposure_id": exposure_id, "device_key": device_key, "name": name}) |
| 76 | +``` |
| 77 | + |
| 78 | +`invoke-request` and `invoke-result` reuse existing shape. The hub injects `connection_id` as the exposure_id when routing. |
| 79 | + |
| 80 | +## Hub-side surface |
| 81 | + |
| 82 | +```python |
| 83 | +@dataclass |
| 84 | +class ExposureRegistration: |
| 85 | + exposure_id: str # "ex_..." (avoids collision with cx_) |
| 86 | + name: str # human label e.g. "weather-sensor" |
| 87 | + websocket: WebSocket |
| 88 | + manifest: dict # {capabilities: [...], public: bool, ...} |
| 89 | + device_key_hash: str |
| 90 | + pending: dict[str, asyncio.Future] |
| 91 | + created_at: float |
| 92 | + |
| 93 | +class Hub: |
| 94 | + exposures: dict[str, ExposureRegistration] # by exposure_id |
| 95 | + device_keys: dict[str, str] # device_key → exposure_id |
| 96 | + |
| 97 | + async def register_exposure(name, manifest, websocket, desired_device_key) -> (id, key) |
| 98 | + async def unregister_exposure(exposure_id) |
| 99 | + def find_exposure_by_capability(capability_name) -> Optional[exposure_id] |
| 100 | + def list_public_exposures() -> list[dict] # for GET /exposures |
| 101 | + async def invoke_exposure(exposure_id, capability, args, timeout) -> dict |
| 102 | +``` |
| 103 | + |
| 104 | +## HTTP endpoints |
| 105 | + |
| 106 | +### `GET /exposures` |
| 107 | +Public-flagged exposures. Returns: |
| 108 | +```json |
| 109 | +[{ |
| 110 | + "exposure_id": "ex_abc", |
| 111 | + "name": "weather-sensor", |
| 112 | + "description": "...", |
| 113 | + "capabilities": ["weather_lookup"], |
| 114 | + "uptime_seconds": 142 |
| 115 | +}] |
| 116 | +``` |
| 117 | +No auth. Operators who don't want their device discoverable set `public: false`. |
| 118 | + |
| 119 | +### `POST /exposures/{exposure_id}/invoke` |
| 120 | +Auth: `Bearer <any registered publisher's zk_ key>`. Body: `{capability, args}`. JSON-Schema-validates against the exposure's declared schema (same plumbing as `/v1/invoke`). Returns `{ok, result}`. |
| 121 | + |
| 122 | +## Persistence |
| 123 | + |
| 124 | +New SQLite table: |
| 125 | + |
| 126 | +```sql |
| 127 | +CREATE TABLE exposures ( |
| 128 | + exposure_id TEXT PRIMARY KEY, |
| 129 | + name TEXT NOT NULL, |
| 130 | + manifest_json TEXT NOT NULL, |
| 131 | + device_key_hash TEXT NOT NULL, |
| 132 | + first_seen INTEGER NOT NULL, |
| 133 | + last_seen INTEGER NOT NULL |
| 134 | +); |
| 135 | +``` |
| 136 | + |
| 137 | +Re-registration: device passes back its `dx_...` key, hub matches the hash, gives back the same `exposure_id`. `device_key` survives hub restart same as `zk_` keys. |
| 138 | + |
| 139 | +## Publisher-side auto-use (opt-in) |
| 140 | + |
| 141 | +A publisher's manifest can set `use_global_exposures: true`. When true, the hub's existing `build_tools_for(ai_name)` (Phase 1.9) ALSO appends every public exposure's capabilities to the `tools` array. The AI sees them in chat-requests; emitted `tool_calls` matching an exposure's capability route through the auto-resolve loop (Phase 1.8) into `invoke_exposure` instead of `invoke_capability`. |
| 142 | + |
| 143 | +The wire shape for the AI is identical — it doesn't know whether a tool came from a connection or an exposure. |
| 144 | + |
| 145 | +## Tests |
| 146 | + |
| 147 | +`tests/test_exposures.py`: |
| 148 | +1. `register_exposure_returns_id_and_key` — WS register-exposure → hub returns ex_... + dx_... |
| 149 | +2. `re_register_with_existing_key_preserves_id` — restart-equivalent: same device_key → same exposure_id |
| 150 | +3. `get_exposures_lists_public_only` — public:true is listed, public:false isn't |
| 151 | +4. `invoke_via_http_with_publisher_key` — POST /exposures/<id>/invoke with a publisher's zk_ → handler fires, result returns |
| 152 | +5. `invoke_rejects_unauthorized` — no bearer or unknown key → 401 |
| 153 | +6. `invoke_404_when_exposure_offline` — device WS dropped → 404 |
| 154 | +7. `tool_call_from_publisher_routes_to_exposure` — publisher with use_global_exposures:true emits tool_call matching exposure cap; hub auto-resolves via exposure path; final text returned |
| 155 | + |
| 156 | +`tests/test_exposures_persistence.py`: |
| 157 | +8. SQLite round-trip: add exposure, list, delete |
| 158 | + |
| 159 | +## Out of scope (future) |
| 160 | + |
| 161 | +- Per-exposure access policy (whitelist of AI names / publisher keys allowed to invoke) |
| 162 | +- Cross-hub exposures via federation |
| 163 | +- Exposure manifest signing (ed25519 like publisher manifests) |
| 164 | +- Capability streaming (server-sent results during long-running invokes) |
| 165 | +- A `dispose()` HTTP for operator-side cleanup of stale exposures |
| 166 | + |
| 167 | +## Files |
| 168 | + |
| 169 | +- `zhub/protocol.py` — `register_exposure`, `exposure_registered` helpers |
| 170 | +- `zhub/persistence.py` — `exposures` table + `add_exposure`, `lookup_exposure`, `all_exposures`, `remove_exposure` |
| 171 | +- `zhub/server.py` — `ExposureRegistration` dataclass, `Hub.register_exposure` + lookups, `/ws/expose` handler, `GET /exposures`, `POST /exposures/{id}/invoke`, opt-in injection in `build_tools_for` |
| 172 | +- `zhub/client.py` — new `expose()` function (mirrors `connect()`) |
| 173 | +- `tests/test_exposures.py` |
| 174 | +- `tests/test_exposures_persistence.py` |
| 175 | + |
| 176 | +Estimated ~300 LOC + tests. |
0 commit comments