Skip to content

Commit 2ab89b0

Browse files
Zawwarsami16claude
andcommitted
spec: phase 7.0 capability-only WebSocket — devices untethered from any one AI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a1a638d commit 2ab89b0

1 file changed

Lines changed: 176 additions & 0 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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

Comments
 (0)