@origonai/web-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
1
+ # MoQ-Switch — WebTransport Client Integration Guide
2
+
3
+ For frontend engineers building a **browser** (or any WebTransport-capable
4
+ runtime) client that joins a media-server call.
5
+
6
+ > **Read [`client-integration.md`](client-integration.md) first** for the
7
+ > wire-level reference (control messages, audio framing, error taxonomy,
8
+ > reconnect tiers). This doc only covers what is **WebTransport-specific**.
9
+ > The protocol on top of WT is byte-for-byte identical to the native QUIC
10
+ > path.
11
+
12
+ ---
13
+
14
+ ## 1. What WebTransport buys you
15
+
16
+ WebTransport is the browser's standardized API for QUIC-shaped streams +
17
+ datagrams over HTTP/3. The browser can't open a raw QUIC connection; it
18
+ *can* open a WebTransport session, which gives you:
19
+
20
+ - Bidirectional sub-streams (≈ QUIC bidi streams)
21
+ - Unidirectional sub-streams (≈ QUIC uni streams)
22
+ - Datagrams (≈ QUIC datagrams)
23
+ - Connection lifetime + close events
24
+
25
+ The media-server treats a WebTransport session as a 1:1 substitute for
26
+ a raw QUIC connection from the native client. **Same Connect handshake,
27
+ same ControlMessages, same audio datagrams.**
28
+
29
+ ---
30
+
31
+ ## 2. Transport setup
32
+
33
+ | Item | Value |
34
+ |---|---|
35
+ | URL | `https://<media-server-host>:<port>/web` |
36
+ | ALPN | `h3` (negotiated automatically by the browser) |
37
+ | Protocol header (sent automatically) | `:protocol = webtransport` |
38
+ | TLS | 1.3 mandatory; WebPKI-trusted cert in prod, see §10 for dev |
39
+ | Datagrams | required (server requires; browser supports natively) |
40
+ | Path | `/web` (default; configurable on the server via `[server] wt_path`) |
41
+
42
+ ### 2.1 Opening the session
43
+
44
+ ```js
45
+ const wt = new WebTransport('https://media.example.com/web', {
46
+ // Production: omit. Browser uses the WebPKI trust store.
47
+ // Development with a self-signed cert, see §10:
48
+ // serverCertificateHashes: [{ algorithm: 'sha-256', value: <ArrayBuffer> }],
49
+ })
50
+
51
+ await wt.ready // throws if the handshake failed
52
+ console.log('connected; closed=', wt.closed)
53
+ ```
54
+
55
+ `wt.closed` is a Promise that resolves (or rejects) when the session
56
+ ends. Treat it as your "session lost" signal.
57
+
58
+ There is **no explicit ALPN knob** in the WebTransport API. The browser
59
+ negotiates `h3` for you; the server's dual-ALPN listener accepts it.
60
+
61
+ ### 2.2 Setting up the audio path
62
+
63
+ The browser cannot use the OS WebRTC pipeline through WebTransport
64
+ directly; you control the encoder yourself.
65
+
66
+ | Concern | Recommended primitive |
67
+ |---|---|
68
+ | Mic capture | `getUserMedia({ audio: true })` → `MediaStreamTrackProcessor` |
69
+ | Opus encode | `AudioEncoder` from WebCodecs API (`codec: 'opus'`) |
70
+ | Opus decode (per peer) | `AudioDecoder` (`codec: 'opus'`) per `PeerAttached.alias` |
71
+ | Playback | Each decoder's PCM into an `AudioWorkletNode` mixer → `AudioContext.destination` |
72
+ | Frame sizing | Encoder configured for 20 ms (960 samples @ 48 kHz mono) |
73
+
74
+ WebCodecs is Chromium-first today (Safari TP, Firefox behind flag).
75
+ Document a min-supported-browser policy with the product team.
76
+
77
+ ---
78
+
79
+ ## 3. The wire on top of WT — what maps to what
80
+
81
+ | Native client (QUIC) | Browser client (WT) | Carries |
82
+ |---|---|---|
83
+ | Bidi QUIC stream | `wt.createBidirectionalStream()` (client-opened) | ControlMessages — Connect, NegotiateMedia, Hold, Mute, SendDtmf, Resume + their `*Ok` + server-push |
84
+ | QUIC datagrams | `wt.datagrams` (readable + writable streams) | Audio frames (one frame per datagram), uplink + downlink |
85
+ | QUIC uni stream (server→client) | `wt.incomingUnidirectionalStreams` | DTMF events when the peer pressed a digit |
86
+ | QUIC graceful close | `wt.close({ closeCode, reason })` | Hangup |
87
+
88
+ Everything in [`client-integration.md`](client-integration.md) §4–§12 applies
89
+ unchanged. Substitute "QUIC bidi stream" with "WT bidi sub-stream" and
90
+ "QUIC datagram" with "WT datagram." That's it.
91
+
92
+ ---
93
+
94
+ ## 4. The control stream (bidi sub-stream)
95
+
96
+ ```js
97
+ // Open the control stream — must be the first thing you do.
98
+ const ctrl = await wt.createBidirectionalStream()
99
+ const writer = ctrl.writable.getWriter()
100
+ const reader = ctrl.readable.getReader()
101
+ ```
102
+
103
+ ### 4.1 Framing
104
+
105
+ Every ControlMessage on the bidi stream is:
106
+
107
+ ```
108
+ +------------------+------------------+-----------------+
109
+ | msg_type (vi) | payload_len (vi) | payload (bytes) |
110
+ +------------------+------------------+-----------------+
111
+ ```
112
+
113
+ `(vi)` = QUIC varint (1/2/4/8 bytes, big-endian, RFC 9000 §16).
114
+
115
+ Cap: **64 KiB per framed message.** Server closes with `ProtocolViolation`
116
+ if you exceed.
117
+
118
+ > **Ground truth.** Use [`moq-core/codec/src/wire/control.rs`](../../moq-core/codec/src/wire/control.rs)
119
+ > as the authoritative encoder/decoder source. Field ordering, varint
120
+ > widths, and KVP layout are precise; do not roll your own.
121
+
122
+ ### 4.2 Connect — first frame, always
123
+
124
+ ```js
125
+ // Pseudo-typescript; encoder is your moq-codec port.
126
+ const connect = encodeControlMessage('Connect', {
127
+ request_id: 1n,
128
+ call_token: jwtBytes,
129
+ sdk_name: 'web-sdk',
130
+ sdk_version: '1.0.0',
131
+ offers: [{
132
+ class: 'Audio',
133
+ purpose: 'Primary',
134
+ codecs: ['Opus', 'PCMU', 'PCMA'], // server picks Opus
135
+ }],
136
+ frame_size_ms: 20,
137
+ })
138
+ await writer.write(connect)
139
+
140
+ const connectOk = await readNextControlMessage(reader)
141
+ // → { type: 'ConnectOk', request_id: 1n, server_epoch_unix, bindings: [{ alias, codec, … }], rejected: [] }
142
+
143
+ const audioAlias = connectOk.bindings[0].alias
144
+ const serverEpochUnix = connectOk.server_epoch_unix
145
+ ```
146
+
147
+ Server-side gates that will close the session if violated:
148
+ - First frame on this bidi must be `Connect`. Anything else → close.
149
+ - A datagram or uni stream that arrives before `ConnectOk` is rejected.
150
+
151
+ ### 4.3 Mid-call verbs + server-push
152
+
153
+ After `ConnectOk` the bidi multiplexes:
154
+ - Your verbs out (`Hold`, `Mute`, `SendDtmf`, `Resume`, `NegotiateMedia`).
155
+ - Server `*Ok` responses (correlate by `request_id`).
156
+ - Server-push events (`PeerAttached`, `PeerDetached`, `EndpointHeld`,
157
+ `EndpointMuted`, `PeerStateChanged`, `DtmfReceived`, `EndpointHangup`,
158
+ `MoqError`) — these carry `request_id = 0`.
159
+
160
+ A single read loop on `ctrl.readable.getReader()` is sufficient. Dispatch
161
+ by `msg_type`.
162
+
163
+ ### 4.4 Bytes may arrive split
164
+
165
+ WT streams give you `Uint8Array` chunks at arbitrary boundaries. Buffer
166
+ until you can decode at least the `msg_type | payload_len` prefix, then
167
+ the full payload, then dispatch. Treat the reader as a byte stream, not
168
+ a message stream.
169
+
170
+ ---
171
+
172
+ ## 5. Audio datagrams
173
+
174
+ ### 5.1 Uplink (you → server)
175
+
176
+ ```js
177
+ const dgWriter = wt.datagrams.writable.getWriter()
178
+
179
+ // Per encoded Opus frame from your AudioEncoder:
180
+ async function sendFrame(encodedBytes, objectIdInGroup) {
181
+ const groupId = BigInt(Math.floor(Date.now() / 1000)) - BigInt(serverEpochUnix)
182
+ const datagram = encodeObjectDatagram({
183
+ track_alias: BigInt(audioAlias),
184
+ group_id: groupId,
185
+ object_id: BigInt(objectIdInGroup), // 0..49 within the wall-second
186
+ publisher_priority: 0,
187
+ payload: encodedBytes,
188
+ end_of_group: objectIdInGroup === 49,
189
+ })
190
+ await dgWriter.write(datagram)
191
+ }
192
+ ```
193
+
194
+ `group_id` is `(now_seconds - server_epoch_unix)`. `object_id` is the
195
+ position within the wall-second (0..49 at 20 ms cadence). Receivers use
196
+ the pair `(group_id, object_id)` for jitter buffering and gap detection.
197
+
198
+ `wt.datagrams.maxDatagramSize` gives the usable payload budget. Opus
199
+ 40 B and PCMU/PCMA 160 B at 20 ms both fit any realistic MTU.
200
+
201
+ > See [`moq-core/codec/src/wire/data_stream.rs`](../../moq-core/codec/src/wire/data_stream.rs)
202
+ > for `ObjectDatagram` byte layout.
203
+
204
+ ### 5.2 Downlink (server → you)
205
+
206
+ ```js
207
+ const dgReader = wt.datagrams.readable.getReader()
208
+ const decoders = new Map() // alias → AudioDecoder
209
+
210
+ while (true) {
211
+ const { value, done } = await dgReader.read()
212
+ if (done) break
213
+ const obj = decodeObjectDatagram(value)
214
+ const dec = decoders.get(obj.track_alias)
215
+ if (!dec) continue // alias not yet PeerAttached
216
+ dec.decode(new EncodedAudioChunk({
217
+ type: 'key',
218
+ timestamp: derivedFromGroupAndObject(obj.group_id, obj.object_id),
219
+ data: obj.payload,
220
+ }))
221
+ }
222
+ ```
223
+
224
+ One decoder per `PeerAttached.alias`. On `PeerDetached`, close the
225
+ decoder and drop the entry.
226
+
227
+ ### 5.3 Loss handling
228
+
229
+ Datagrams are unreliable — gaps happen. Detect via `object_id` gap,
230
+ ask `AudioDecoder` for a concealment frame (`AudioDecoder.flush()` or
231
+ manual PLC). Server never retransmits.
232
+
233
+ ---
234
+
235
+ ## 6. DTMF inbound (unidirectional sub-stream)
236
+
237
+ DTMF *from a peer* arrives on a fresh server-initiated uni stream, one
238
+ per digit:
239
+
240
+ ```js
241
+ const uniReader = wt.incomingUnidirectionalStreams.getReader()
242
+ while (true) {
243
+ const { value: stream, done } = await uniReader.read()
244
+ if (done) break
245
+ // stream is a ReadableStream<Uint8Array>
246
+ handleDtmfUniStream(stream) // decodes SubgroupHeader + DtmfEvent + EndOfGroup
247
+ }
248
+ ```
249
+
250
+ The framing on this stream is documented in
251
+ [`docs/wire-protocol.md`](wire-protocol.md) §"Stream paths (non-audio)".
252
+ The payload is a single `DtmfEvent { digit, duration_ms }` protobuf
253
+ message.
254
+
255
+ DTMF *to the server* (you pressing a digit) is the `SendDtmf` verb on
256
+ the bidi control stream — not a uni stream. The browser cannot open
257
+ WT uni sub-streams at all in some specs/browsers, so we never required
258
+ it for the outbound direction.
259
+
260
+ ---
261
+
262
+ ## 7. Session control verbs
263
+
264
+ Same shape as native. See [`client-integration.md`](client-integration.md)
265
+ §7 for full details. Quick reminder:
266
+
267
+ | Verb | Direction | Cap claim required |
268
+ |---|---|---|
269
+ | `Hold { held: bool }` | Client → Server | `session.hold` |
270
+ | `Mute { scope: Uplink \| Downlink \| Both \| None }` | Client → Server | `session.mute_self` |
271
+ | `SendDtmf { digit, duration_ms }` | Client → Server | `dtmf_send` |
272
+ | `Resume { call_token, last_event_seq }` | Client → Server | (token only) |
273
+ | `NegotiateMedia { offers, frame_size_ms }` | Client → Server | (token only; additive) |
274
+
275
+ The corresponding `*Ok` responses carry the echoed `request_id`. Server
276
+ pushes the matching state-change event (`EndpointHeld`, `EndpointMuted`,
277
+ `PeerStateChanged`, `DtmfReceived`) for other peers' UI consistency.
278
+
279
+ ---
280
+
281
+ ## 8. Hangup
282
+
283
+ ```js
284
+ wt.close({ closeCode: 0, reason: '' })
285
+ ```
286
+
287
+ Same semantics as the native client's QUIC `APPLICATION_CLOSE`:
288
+
289
+ | Reason | closeCode |
290
+ |---|---|
291
+ | Normal | `0x00` |
292
+ | User cancel | `0x01` |
293
+ | App error | `0x02` |
294
+
295
+ The server treats the WT close as a graceful hangup (ADR-0015), tears
296
+ down the bridge, and emits `EndpointHangup{origin: Client}` to remaining
297
+ peers.
298
+
299
+ `wt.closed` Promise resolves with the close info when the server (or
300
+ the network) closes from its side.
301
+
302
+ ---
303
+
304
+ ## 9. Reconnect — what's different from the native path
305
+
306
+ | Native (QUIC) | Browser (WT) |
307
+ |---|---|
308
+ | Tier 1 — connection migration | **Not available.** Browser does not expose QUIC migration. |
309
+ | Tier 2 — 0-RTT resumption | **Not available.** WebTransport API does not expose 0-RTT. |
310
+ | Tier 3 — `Resume` verb | **Required path.** Open a fresh WT session, present same JWT (still within `exp`), send `Resume { call_token, last_event_seq }` on the new bidi. |
311
+
312
+ So in the browser, *all* reconnects go through Tier 3. The server holds
313
+ the endpoint suspended for `reconnect_window` (default 30 s) after the
314
+ session drops. Reconnect within that window with the same `eid` (in the
315
+ token) and the bridge state is preserved — `ResumeOk` returns the
316
+ `EndpointSnapshot` (mute, held, peer subscriptions) and you resume
317
+ audio pumps against the same aliases.
318
+
319
+ ```js
320
+ wt.closed.catch(async () => {
321
+ // optional: visual "reconnecting…" state
322
+ for (const delay of [100, 250, 500, 1000]) {
323
+ try {
324
+ const wt2 = new WebTransport(url, opts)
325
+ await wt2.ready
326
+ await sendResume(wt2, { last_event_seq: lastSeenSeq })
327
+ // ResumeOk arrived → swap wt → wt2, rebind pumps
328
+ return
329
+ } catch (e) {
330
+ await new Promise(r => setTimeout(r, delay))
331
+ }
332
+ }
333
+ // give up; surface terminal error
334
+ })
335
+ ```
336
+
337
+ After `reconnect_window` lapses, server returns `EndpointNotProvisioned`
338
+ and the controller has to call `StartModule` again — the app's "start a
339
+ new call" flow.
340
+
341
+ ---
342
+
343
+ ## 10. Certificates
344
+
345
+ ### 10.1 Production
346
+
347
+ Standard WebPKI cert chain. The browser uses the OS / WebPKI trust
348
+ store. Nothing special on the client side — omit
349
+ `serverCertificateHashes`.
350
+
351
+ ### 10.2 Development (self-signed)
352
+
353
+ WebTransport supports `serverCertificateHashes` to trust a specific
354
+ cert without a CA chain, **but** there are hard constraints:
355
+
356
+ - Cert must use ECDSA-P256 (RSA not allowed).
357
+ - Cert validity ≤ 14 days.
358
+ - Both Subject and Issuer must be present.
359
+ - Hash algorithm must be `sha-256`.
360
+
361
+ ```js
362
+ async function sha256Bytes(pem) {
363
+ const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '')
364
+ const der = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
365
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', der))
366
+ }
367
+
368
+ const certHash = await sha256Bytes(serverCertPem)
369
+ const wt = new WebTransport(url, {
370
+ serverCertificateHashes: [{ algorithm: 'sha-256', value: certHash.buffer }],
371
+ })
372
+ ```
373
+
374
+ Hand the dev cert (or just its sha-256) to the JS bundle out-of-band.
375
+ The dev deploy script (`scripts/deploy-dev.sh`) is the right place to
376
+ emit it.
377
+
378
+ ---
379
+
380
+ ## 11. Errors and error codes
381
+
382
+ All app-level errors arrive as a typed `MoqError` ControlMessage on the
383
+ bidi stream **before** the session is closed. Same enum as native — see
384
+ [`client-integration.md` §10](client-integration.md):
385
+
386
+ | Code | Meaning |
387
+ |---|---|
388
+ | `0x1001 EndpointNotProvisioned` | Token's `eid` does not match a live endpoint. |
389
+ | `0x1002 EndpointAlreadyConnected` | Another session holds this endpoint. |
390
+ | `0x1010 TokenInvalid` | Sig / iss / aud / schema check failed. |
391
+ | `0x1011 TokenExpired` | `exp` past. |
392
+ | `0x1012 TokenReplayed` | `jti` already seen in the cache. |
393
+ | `0x1020 ProtocolViolation` | Bad frame ordering, oversize, etc. |
394
+ | `0x1021 CapabilityMissing` | Verb requires a `cap` you don't have. |
395
+ | `0x1022 IllegalState` | Verb invalid in current state. |
396
+ | `0x1030 ResourceExhausted` | Server capacity hit. |
397
+ | `0x1031 ReplayLost` | Resume gap too wide. |
398
+ | `0x1040 SessionEnded` | Server closed the session cleanly. |
399
+
400
+ Transport-level failures (cert rejected, server unreachable, network
401
+ loss) surface as a rejection on `wt.ready` or `wt.closed` — no
402
+ `MoqError` because the session never opened or already dropped.
403
+
404
+ ---
405
+
406
+ ## 12. Browser limitations to design around
407
+
408
+ | Limitation | Workaround |
409
+ |---|---|
410
+ | No `Origin` enforcement in WT API | Server doesn't gate on Origin — auth is JWT in the framed Connect, not cookies. |
411
+ | No custom request headers on the WT CONNECT | Auth rides the Connect ControlMessage, not headers. |
412
+ | WebCodecs Opus support varies by browser | Detect via `AudioEncoder.isConfigSupported`; fall back to a wasm Opus encoder if you need broader reach. |
413
+ | Datagram backpressure is implicit | Watch `wt.datagrams.writable.getWriter().ready` if you have a bursty sender; for 20 ms audio it's never the bottleneck. |
414
+ | Tab visibility / `requestAnimationFrame` throttling | Audio pumps must use timers (`setInterval` / `AudioWorklet` clock), not rAF. |
415
+ | Same-origin policy for fetch-based token retrieval | The token-mint endpoint must be CORS-enabled or same-origin as the JS bundle. |
416
+
417
+ ---
418
+
419
+ ## 13. Minimal happy-path skeleton
420
+
421
+ ```js
422
+ async function startCall({ url, jwt, certHash /* optional */ }) {
423
+ // 1. Open session
424
+ const wt = new WebTransport(url, certHash
425
+ ? { serverCertificateHashes: [{ algorithm: 'sha-256', value: certHash }] }
426
+ : undefined)
427
+ await wt.ready
428
+
429
+ // 2. Open control bidi sub-stream
430
+ const ctrl = await wt.createBidirectionalStream()
431
+ const writer = ctrl.writable.getWriter()
432
+ const reader = ctrl.readable.getReader()
433
+
434
+ // 3. Send Connect, await ConnectOk
435
+ await writer.write(encodeConnect({
436
+ request_id: 1n,
437
+ call_token: jwt,
438
+ sdk_name: 'web-sdk', sdk_version: '1.0.0',
439
+ offers: [{ class: 'Audio', purpose: 'Primary', codecs: ['Opus'] }],
440
+ frame_size_ms: 20,
441
+ }))
442
+ const connectOk = await readControlMessage(reader)
443
+ const { audioAlias, serverEpochUnix } = parseConnectOk(connectOk)
444
+
445
+ // 4. Spin up the four pumps
446
+ startBidiReadLoop(reader, dispatchEvent) // server-push + verb Oks
447
+ startDatagramSender(wt, audioAlias, serverEpochUnix) // mic → Opus → datagram
448
+ startDatagramReceiver(wt) // datagrams → decoders
449
+ startUniReceiver(wt) // DTMF uni streams
450
+
451
+ // 5. Wait for session end
452
+ wt.closed.then(info => onClosed(info), err => onError(err))
453
+
454
+ return {
455
+ hangup: () => wt.close({ closeCode: 0, reason: '' }),
456
+ sendDtmf: (digit) => sendDtmfVerb(writer, digit),
457
+ hold: (held) => sendHoldVerb(writer, held),
458
+ mute: (scope) => sendMuteVerb(writer, scope),
459
+ }
460
+ }
461
+ ```
462
+
463
+ ---
464
+
465
+ ## 14. Quick reference
466
+
467
+ | Constant | Value |
468
+ |---|---|
469
+ | URL path | `/web` |
470
+ | Control stream | one bidi sub-stream, opened by the client right after `wt.ready` |
471
+ | First frame on the control stream | `Connect` (must) |
472
+ | Audio codec | Opus 48 kHz mono, 20 ms |
473
+ | Datagram frame | one `ObjectDatagram` per audio frame |
474
+ | Max framed control message | 64 KiB |
475
+ | `reconnect_window` | 30 s (server-side; Tier-3 Resume budget) |
476
+ | Token TTL | 4 h (server-issued) |
477
+
478
+ ---
479
+
480
+ ## 15. Where to go next
481
+
482
+ - Wire-level reference: [`client-integration.md`](client-integration.md)
483
+ - Control message byte layout: [`moq-core/codec/src/wire/control.rs`](../../moq-core/codec/src/wire/control.rs)
484
+ - ObjectDatagram byte layout: [`moq-core/codec/src/wire/data_stream.rs`](../../moq-core/codec/src/wire/data_stream.rs)
485
+ - Proto schemas: [`ms-proto/proto/v1/`](../ms-proto/proto/v1/)
@@ -0,0 +1,71 @@
1
+ # Web SDK contract
2
+
3
+ This public SDK consumes the browser chat and session REST surfaces produced by
4
+ `/home/yl/workspace/platform/cx`. Public examples explain usage; this file is the
5
+ reciprocal cross-repository contract ledger.
6
+
7
+ ## Interactive chat prompts
8
+
9
+ - Incoming ORPC messages decode `buttons: [{label,value,type}]` and
10
+ `gallery: [{title,description,image?,buttons}]`; a gallery image is
11
+ legitimately absent. The canonical producer and consumer registry is
12
+ `/home/yl/workspace/platform/cx/CONTRACT.md` (consumers of `buttons` /
13
+ `gallery`), reciprocated by the active `/home/yl/workspace/web/chat` consumer.
14
+ - The public `SendMessagePayload.buttonReply {value,label?}` is a local typed
15
+ adapter. It never rides the wire as a nested object: `ChatSession` maps
16
+ `value` to top-level ORPC `MessageRequest.value` and optional `label` (the
17
+ gallery card title) to `MessageRequest.galleryLabel`; the button caption
18
+ remains `text`. cx's reciprocal consumer registration is its Button / Gallery
19
+ reply-shape entry. This adapter supersedes the original Sprint BG proposal to
20
+ delete `buttonReply` while preserving the canonical wire exactly.
21
+
22
+ ## Session directory continuity hardcut
23
+
24
+ - `GET /sessions` is parsed as `SessionSummary[]` and every row must contain
25
+ `active: boolean`. Missing or non-boolean `active` is a decode failure; there is
26
+ no compatibility default. The producer is `platform/cx` (`CONTRACT.md`,
27
+ "Session directory + history + resume") and the native mirror is
28
+ `workspace/apps/sdk/session/src/types.rs`.
29
+ - `active` is live-owner state, not stored session status. It is meaningful only
30
+ while list/bootstrap and chat ORPC Attach resolve to the same live cx owner.
31
+ - Browser auto-restore and browser push are intentionally absent. The in-workspace
32
+ `web/chat` consumer may display/list the decoded rows but must not turn `active`
33
+ into automatic background attachment.
34
+
35
+ ## Release gate
36
+
37
+ Run typecheck, unit tests, and the production build, then rebuild the linked
38
+ `workspace/web/chat` consumer before publication. Publishing to npm remains an
39
+ owner action and is never part of an implementation spin.
40
+
41
+ ## Provisioned receive-only voice join
42
+
43
+ The active in-workspace consumer is `workspace/web/cx` Insights Live. Its
44
+ page-owned supervision controller passes platform/cx's one-time media offer
45
+ directly to this seam, keeps the token in memory only, and uses
46
+ `enableCapture`/`releaseCapture` for Coach/Listen. Deployed gw route, policy,
47
+ certificate, and live-probe evidence remains the workspace LS-4 gate.
48
+
49
+ `joinSession({channel:'voice', sessionId, url, token,
50
+ voice:{receiveOnly:true}})` is the additive listen-only seam for a media offer
51
+ provisioned by another control plane. It still requires `initialize()` but does
52
+ not require `authenticate()` or `/config`.
53
+
54
+ The SDK starts the audio worklet and WebTransport receive path, awaits
55
+ `ConnectOk`, then sends `Mute(uplink)` and awaits the matching `MuteOk` before
56
+ firing `onConnected`. It never calls `getUserMedia` or enables capture on this
57
+ path. Server mute acknowledgement, not browser state, is the uplink-safety
58
+ boundary. Omitting the option preserves the existing capture-on-connect
59
+ behavior. A failed voice start removes its SessionManager entry and attempts to
60
+ dispose partial resources so the same provisioned id can be retried; a teardown
61
+ failure is reported explicitly without masking the original start failure.
62
+
63
+ `enableCapture(sessionId)` is the serialized Listen→Coach transition. It
64
+ acquires and enables the worklet pipeline while the endpoint is uplink-muted,
65
+ waits for a generation-correlated worklet acknowledgement, awaits
66
+ `Mute(none)`/`MuteOk`, and only then opens the local outbound datagram gate.
67
+ `releaseCapture(sessionId)` closes that gate immediately, awaits
68
+ `Mute(uplink)`/`MuteOk`, disables capture, disconnects capture/diagnostic nodes,
69
+ stops every microphone track, and nulls the stream. Permission, worklet, mute,
70
+ transport, terminal-event, and teardown races fail closed: late generations and
71
+ stale transport acknowledgements cannot reopen capture or resolve new requests.
@@ -0,0 +1,91 @@
1
+ # Chat protocol: wire shape + extension rule
2
+
3
+ The chat wire is the **`chat.v1` protobuf schema** spoken over
4
+ oRPC-over-WebTransport (since 0.5.0; the SSE lane is deleted — there is
5
+ no fallback transport). The schema's canonical source is
6
+ `platform/cx/protos/chat.proto` in the Origon workspace, vendored into
7
+ this repo as `src/orpc-gen/chat_pb.ts`; the SDK decodes exactly the
8
+ arms that descriptor carries.
9
+
10
+ ## The wire, in one table
11
+
12
+ | Direction | RPC (`chat.v1.Session/…`) | Payload |
13
+ |-----------|-------------------------------|---------|
14
+ | attach | `Attach` (server-streaming) | initial `AttachResponse {participantId, role}`, then one `ChatEvent` per frame |
15
+ | send | `Message` (unary) | `MessageRequest {clientMessageId, text?, html?, attachments, value?, galleryLabel?}` → the canonical `Message` echo |
16
+ | typing | `Typing` (unary) | `TypingRequest {state: "on"\|"off"}` |
17
+ | leave | `Leave` (unary) | empty; immediate server-side finalize |
18
+
19
+ `ChatEvent` is a oneof: `message`, `typing`, `participant_joined`,
20
+ `participant_left`, `session_ended`, `heartbeat`. The SDK surfaces
21
+ `message`, `typing` and `session_ended`; everything else — and any arm
22
+ it does not know — is consumed and dropped. That tolerance is the
23
+ forward-compat contract.
24
+
25
+ ## The extension rule: proto arms first
26
+
27
+ **A chat feature does not exist until it has a proto arm.** The path for
28
+ any new server→client event or client→server field is:
29
+
30
+ 1. **Proto** — the arm lands in cx's `chat.proto` (a new `ChatEvent`
31
+ oneof arm, or a new `MessageRequest` field) and cx emits/reads it.
32
+ 2. **Re-vendor** — the monorepo's `web/kit/scripts/ship-orpc.sh`
33
+ regenerates `src/orpc-gen/` here (never hand-edited).
34
+ 3. **SDK surface** — a decode arm in `src/chat/orpc.ts` and, if the
35
+ consumer needs it, a callback/payload field.
36
+
37
+ The SSE era did this backwards: the SDK shipped designed-ahead callbacks
38
+ (`onControlUpdated`, `onToolCalls`, `onAssistantModeChanged`) and
39
+ `sendMessage` fields for events no backend ever emitted. Those inert
40
+ surfaces were **deleted with the transport** (owner decision,
41
+ 2026-08-06). If control-handoff, tool-calls or mode-switch semantics
42
+ return, they re-enter through the three steps above.
43
+
44
+ ## Streaming AI replies (live behavior)
45
+
46
+ For AI responses the backend streams the reply as multiple `message`
47
+ events carrying the **same `id`** with `state: "streaming"`, terminated
48
+ by one final event with `state: "completed"`.
49
+
50
+ - First chunk with a new `id` → `onMessageAdded(message)` — the
51
+ consumer renders a new row.
52
+ - Subsequent chunks with the same `id` → `onMessageUpdated({id, message})`.
53
+ - `text`/`html` is the complete cumulative content so far, **not a
54
+ delta** — consumers replace, not append.
55
+ - One-shot messages are a single event with `state: "completed"`; an
56
+ unknown/absent `state` degrades to `"completed"`.
57
+
58
+ ## Prompt buttons + gallery (live behavior)
59
+
60
+ A `message` may carry `buttons` (`MessageButton {label, value, type}`)
61
+ and/or `gallery` (`MessageCard {title, description, image?, buttons}`).
62
+ The reply leg is `SendMessagePayload.buttonReply {value, label?}`, which
63
+ the SDK maps onto the wire's top-level `value` / `galleryLabel`.
64
+
65
+ ## Role mapping
66
+
67
+ The SDK's `MessageRole` is `'ai' | 'external' | 'user' | 'system'`;
68
+ unknown wire roles degrade to `'external'` (the visitor side is the safe
69
+ default). Mapping vs. the legacy chat-sdk:
70
+
71
+ | Legacy role | New role | Who it represents |
72
+ |--------------|--------------|-------------------------------------------------------------|
73
+ | `assistant` | `ai` | AI bot |
74
+ | `user` | `external` | End-user / customer (the visitor in the chat widget) |
75
+ | `supervisor` | `user` | Staff / internal operator (the human agent on the dashboard)|
76
+ | `system` | `system` | System-injected messages |
77
+
78
+ ## Outbound payload: what actually rides the wire
79
+
80
+ `sendMessage`'s wire carrier is `MessageRequest`:
81
+ `clientMessageId` (SDK-minted idempotency key), `text`, `html`,
82
+ `attachments` (ids/names only — URLs are server-assigned on the echo),
83
+ `value`, `galleryLabel`. The public payload surface also accepts the local
84
+ typed `buttonReply {value,label?}` adapter and maps it to those two top-level
85
+ wire fields; the nested adapter never rides ORPC. Two other local-only fields
86
+ are stripped before send: `role` (the provisional-row hint) and
87
+ `attachments[].localUrl` (preview blob). The
88
+ SSE-era fields with no wire carrier (`type`/`results`/`meta`/`context`/
89
+ `createSystem`/`mode`) were deleted with 0.5.0 (owner decision,
90
+ 2026-08-07) — a new outbound field starts with a proto arm, per the
91
+ extension rule above.