@relaymessenger/sdk 0.3.6-staging.5 → 0.3.6-staging.51

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.
Files changed (88) hide show
  1. package/README.md +511 -29
  2. package/dist/a2ui.d.ts +111 -0
  3. package/dist/a2ui.d.ts.map +1 -0
  4. package/dist/a2ui.js +137 -0
  5. package/dist/a2ui.js.map +1 -0
  6. package/dist/buttons.d.ts +7 -2
  7. package/dist/buttons.d.ts.map +1 -1
  8. package/dist/buttons.js +13 -11
  9. package/dist/buttons.js.map +1 -1
  10. package/dist/calls/call-room.d.ts +110 -0
  11. package/dist/calls/call-room.d.ts.map +1 -0
  12. package/dist/calls/call-room.js +616 -0
  13. package/dist/calls/call-room.js.map +1 -0
  14. package/dist/calls/engine-werift-video.d.ts +83 -0
  15. package/dist/calls/engine-werift-video.d.ts.map +1 -0
  16. package/dist/calls/engine-werift-video.js +607 -0
  17. package/dist/calls/engine-werift-video.js.map +1 -0
  18. package/dist/calls/engine-werift.d.ts +48 -0
  19. package/dist/calls/engine-werift.d.ts.map +1 -0
  20. package/dist/calls/engine-werift.js +462 -0
  21. package/dist/calls/engine-werift.js.map +1 -0
  22. package/dist/calls/index.d.ts +11 -0
  23. package/dist/calls/index.d.ts.map +1 -0
  24. package/dist/calls/index.js +11 -0
  25. package/dist/calls/index.js.map +1 -0
  26. package/dist/calls/transport.d.ts +466 -0
  27. package/dist/calls/transport.d.ts.map +1 -0
  28. package/dist/calls/transport.js +1409 -0
  29. package/dist/calls/transport.js.map +1 -0
  30. package/dist/calls/video-frame.d.ts +56 -0
  31. package/dist/calls/video-frame.d.ts.map +1 -0
  32. package/dist/calls/video-frame.js +245 -0
  33. package/dist/calls/video-frame.js.map +1 -0
  34. package/dist/calls/video-presets.d.ts +39 -0
  35. package/dist/calls/video-presets.d.ts.map +1 -0
  36. package/dist/calls/video-presets.js +40 -0
  37. package/dist/calls/video-presets.js.map +1 -0
  38. package/dist/calls/video-rtp.d.ts +51 -0
  39. package/dist/calls/video-rtp.d.ts.map +1 -0
  40. package/dist/calls/video-rtp.js +294 -0
  41. package/dist/calls/video-rtp.js.map +1 -0
  42. package/dist/calls/video.d.ts +195 -0
  43. package/dist/calls/video.d.ts.map +1 -0
  44. package/dist/calls/video.js +228 -0
  45. package/dist/calls/video.js.map +1 -0
  46. package/dist/client.d.ts +197 -8
  47. package/dist/client.d.ts.map +1 -1
  48. package/dist/client.js +437 -16
  49. package/dist/client.js.map +1 -1
  50. package/dist/errors.d.ts +11 -0
  51. package/dist/errors.d.ts.map +1 -1
  52. package/dist/errors.js +16 -0
  53. package/dist/errors.js.map +1 -1
  54. package/dist/index.d.ts +8 -3
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +8 -3
  57. package/dist/index.js.map +1 -1
  58. package/dist/links.d.ts +58 -0
  59. package/dist/links.d.ts.map +1 -0
  60. package/dist/links.js +127 -0
  61. package/dist/links.js.map +1 -0
  62. package/dist/operations.d.ts +97 -9
  63. package/dist/operations.d.ts.map +1 -1
  64. package/dist/operations.js +129 -10
  65. package/dist/operations.js.map +1 -1
  66. package/dist/payment.d.ts +67 -0
  67. package/dist/payment.d.ts.map +1 -0
  68. package/dist/payment.js +147 -0
  69. package/dist/payment.js.map +1 -0
  70. package/dist/reply-target.d.ts +26 -0
  71. package/dist/reply-target.d.ts.map +1 -0
  72. package/dist/reply-target.js +59 -0
  73. package/dist/reply-target.js.map +1 -0
  74. package/dist/selection.d.ts +46 -0
  75. package/dist/selection.d.ts.map +1 -0
  76. package/dist/selection.js +142 -0
  77. package/dist/selection.js.map +1 -0
  78. package/dist/types.d.ts +1075 -78
  79. package/dist/types.d.ts.map +1 -1
  80. package/dist/websocket.d.ts +6 -0
  81. package/dist/websocket.d.ts.map +1 -1
  82. package/dist/websocket.js +29 -10
  83. package/dist/websocket.js.map +1 -1
  84. package/package.json +38 -3
  85. package/dist/calls-room.d.ts +0 -50
  86. package/dist/calls-room.d.ts.map +0 -1
  87. package/dist/calls-room.js +0 -319
  88. package/dist/calls-room.js.map +0 -1
package/README.md CHANGED
@@ -2,6 +2,231 @@
2
2
 
3
3
  TypeScript client for Relay v1.
4
4
 
5
+ ## Selection
6
+
7
+ The SDK exposes `SelectionPart`, `SelectionPartResponse` (including read-only
8
+ viewer-relative `has_responded`), and `SelectionResponsePart` through the
9
+ existing message and event unions.
10
+
11
+ ```ts
12
+ import { partsWithSelection } from "@relaymessenger/sdk";
13
+
14
+ const parts = partsWithSelection("I can go deep on any of these.", {
15
+ type: "selection",
16
+ title: "Topics",
17
+ options: [
18
+ { value: "research", label: "Research" },
19
+ { value: "design", label: "Design" },
20
+ ],
21
+ });
22
+ // Send with relay.chats.messages.send(chatId, { message: { parts, idempotency_key } }).
23
+ ```
24
+
25
+ The question goes in `title`: trimmed, 1 to 60 characters, a few words such as
26
+ "Pizza toppings". It is the card's title in the chat and the sheet's title. The
27
+ text part is optional; when present it is an ordinary chat bubble above the
28
+ card, and `partsWithSelection(undefined, selection)` sends the selection alone.
29
+
30
+ `selectionPart` validates a complete part (`type` may be left out), returning a
31
+ normalized part or an error string. The title is limited to 60 characters,
32
+ options to 25, trimmed labels to 80 characters, and explicit case-sensitive
33
+ ASCII token values to 100. Unknown fields, duplicate values, a missing or blank
34
+ title, and blank labels are rejected.
35
+
36
+ Selection inherits existing Chat membership rules: at most one human user,
37
+ with multiple agents allowed. Only the human user can respond; agents cannot.
38
+ The durable per-user response claim applies across that user's devices and
39
+ idempotency keys, without expanding group membership.
40
+
41
+ `answerMessages` accepts a `selection` fenced JSON block containing
42
+ `{"title": "...", "options": [...]}`; the words outside it, if any, become the
43
+ text above the card. It keeps invalid blocks as text with an error and never combines a
44
+ selection with buttons. Existing buttons retain their behavior.
45
+
46
+ The person opens the prompt, checks any number of options and submits them
47
+ once; checking sends nothing and only the submit does. A person answers a given
48
+ selection once, and reopening it afterwards shows what they chose without
49
+ letting them change it.
50
+
51
+ The user's new reply is exactly literal `• ` + each selected source label
52
+ joined with `\n`, then
53
+ `{ type: "selection_response", selected_values: ["research", "design"] }`,
54
+ in source-option order, with explicit `reply_to.message_id` and `part_index`.
55
+ The user client keeps its existing outgoing idempotency identity for retries.
56
+ The server also accepts exact legacy comma-joined source labels only for
57
+ compatibility. iOS may draw a checkmark in place of each bullet and repeat the
58
+ prompt's title, as presentation only, but portable text stays bulleted.
59
+ Metadata has no additional display text. Use `selected_values` and the source
60
+ reply target to dispatch your own handler, rather than splitting labels.
61
+ Signed webhook `unwrap` and WebSocket `onEvent` default types expose this
62
+ metadata after narrowing to `message.received`.
63
+
64
+ The CLI, Pi, OpenClaw, the Claude Code channel, MCP, and the Chat SDK adapter
65
+ include selection guidance and structured inbound discovery.
66
+ `selectionReply(parts, replyTo)` discovers values and the explicit source target.
67
+
68
+ ## Payment
69
+
70
+ Ask a person to pay in two steps. Create a payment request on your
71
+ organization's connected Stripe account, then send its `checkout_url` as a
72
+ `payment` part, the only part of its Message. The card reads its amount and
73
+ title from the request. The money settles to your Stripe account.
74
+
75
+ ```ts
76
+ const request = await relay.paymentRequests.create(
77
+ {
78
+ amount: 2400, // minor units
79
+ currency: "usd",
80
+ description: "House blend, 250 g",
81
+ category: "physical_goods", // or "digital_goods", "donation"
82
+ },
83
+ { idempotencyKey: `order-${orderId}` },
84
+ );
85
+ await relay.chats.messages.send(chatId, {
86
+ message: {
87
+ parts: [{ type: "payment", checkout_url: request.checkout_url }],
88
+ idempotency_key: crypto.randomUUID(),
89
+ },
90
+ });
91
+ ```
92
+
93
+ For a subscription, pass `mode: "subscription"` and a recurring `price_id`
94
+ instead of `amount` and `currency`. `relay.paymentRequests.list`, `retrieve`
95
+ and `cancel` read and cancel requests. The status moves only on Stripe's word
96
+ or your cancel, and arrives as `payment.succeeded`, `payment.canceled` or
97
+ `payment.expired`; a paid request also adds a `payment_receipt` message from
98
+ the payer, which arrives as `message.received`.
99
+
100
+ A model inside a bridge never holds the Relay token, so it gives the request's
101
+ fields and the bridge creates it. `answerMessages` lifts one `payment` fenced
102
+ JSON block (`description`, `category`, `amount` and `currency`, or
103
+ `mode: "subscription"` with `price_id`) out of the words and returns it as
104
+ `payment`, checked by `paymentRequestFields`; `createPaymentPart` creates the
105
+ request on the card Message's own idempotency key and returns the `payment`
106
+ part to send last. A second payment, or a payment beside buttons or a
107
+ selection, stays text with an error. `PAYMENT_GUIDANCE` is the text the CLI,
108
+ Pi, OpenClaw, MCP and the Claude Code channel carry; the text bridges also
109
+ carry `PAYMENT_BLOCK_INSTRUCTION`.
110
+
111
+ ## Location
112
+
113
+ Ask the person in a one-to-one chat to share their location, then read it.
114
+ The request puts a `location_request` Message from your agent in the chat;
115
+ the person chooses whether to share and for how long.
116
+
117
+ ```ts
118
+ await relay.chats.location.request(chatId);
119
+
120
+ // After `location.sharing.started` arrives:
121
+ const { data } = await relay.chats.location.retrieve(chatId);
122
+ for (const feature of data.features) {
123
+ const [longitude, latitude] = feature.geometry.coordinates;
124
+ console.log(feature.properties.handle, latitude, longitude, feature.properties.updated_at);
125
+ }
126
+ ```
127
+
128
+ `data` is a GeoJSON FeatureCollection, longitude first; `features` is empty
129
+ when nobody is sharing. `location.sharing.started` and
130
+ `location.sharing.stopped` fire when a share begins or ends, never when the
131
+ position moves, so read again when you need the latest position. A request
132
+ returns 409 in a group chat, in a chat with no person, or while the person is
133
+ already sharing, and 429 with `Retry-After` after one request in the same chat
134
+ in the last 60 seconds. The person's card arrives as a `location` part that
135
+ carries the share's state, never its position.
136
+
137
+ ## Cards (A2UI)
138
+
139
+ A card is an [A2UI v0.9.1](https://a2ui.org) surface, sent as a `data` part:
140
+ `{ type: "data", media_type: "application/a2ui+json", data: [A2UI messages] }`.
141
+ Send the card, read the tap, then change the same card in place.
142
+
143
+ ```ts
144
+ import Relay, {
145
+ A2UI_BASIC_CATALOG_ID,
146
+ readA2uiAction,
147
+ sendA2uiSurface,
148
+ updateA2uiSurface,
149
+ } from "@relaymessenger/sdk";
150
+
151
+ const relay = new Relay({
152
+ apiKey: process.env.RELAY_AGENT_TOKEN!,
153
+ webhookSecret: process.env.RELAY_WEBHOOK_SECRET!,
154
+ });
155
+
156
+ // 1. Send a card with a button.
157
+ await sendA2uiSurface(relay, chatId, {
158
+ surfaceId: "order-1042",
159
+ catalogId: A2UI_BASIC_CATALOG_ID,
160
+ components: [
161
+ { id: "root", component: "Card", child: "body" },
162
+ { id: "body", component: "Column", children: ["title", "status", "confirm"] },
163
+ { id: "title", component: "Text", text: "Oat latte, large", variant: "h3" },
164
+ { id: "status", component: "Text", text: { path: "/status" } },
165
+ { id: "confirm_label", component: "Text", text: "Confirm order" },
166
+ {
167
+ id: "confirm",
168
+ component: "Button",
169
+ child: "confirm_label",
170
+ variant: "primary",
171
+ action: { event: { name: "confirm_order", context: { order: "1042" } } },
172
+ },
173
+ ],
174
+ dataModel: { status: "Waiting for you" },
175
+ }, { idempotency_key: "order-1042-card" });
176
+
177
+ // 2. The tap arrives as message.received.
178
+ const event = relay.webhooks.unwrap(rawBody, { headers });
179
+ const tap = readA2uiAction(event);
180
+ if (tap?.action.name === "confirm_order" && event.event_type === "message.received") {
181
+ // 3. Change the same card to its done state. No new Message is added.
182
+ await updateA2uiSurface(relay, event.data.chat.id, tap.action.surfaceId, {
183
+ components: [
184
+ { id: "body", component: "Column", children: ["title", "status"] },
185
+ ],
186
+ dataModel: { path: "/status", value: `Confirmed, order ${tap.action.context.order}` },
187
+ });
188
+ }
189
+ ```
190
+
191
+ `sendA2uiSurface` sends `createSurface`, `updateComponents` and, with
192
+ `dataModel`, `updateDataModel` in one data part. `updateA2uiSurface` sends
193
+ `updateComponents` and `updateDataModel` for a surface already in the chat;
194
+ components replace their namesakes by `id`. A surface's first
195
+ `updateComponents` holds the component with the id `root`. `deleteA2uiSurface` sends
196
+ `deleteSurface`; when every surface of a Message is deleted, the Message reads
197
+ back with no parts and a non-null `unsent_at`. Each takes the rest of the
198
+ Message as its fourth argument: `text` becomes a text part before the card,
199
+ and `reply_to`, `idempotency_key`, `silent` and `metadata` pass through.
200
+ `a2uiPart` wraps any list of A2UI messages in a data part for
201
+ `relay.chats.messages.send`.
202
+
203
+ Relay applies each A2UI message on its own, checked against A2UI's schemas and
204
+ the catalog the surface names; a component, property or value the catalog does
205
+ not define is refused. The messages it could not apply come back in the
206
+ response's `a2ui_errors`: each gives `part_index` and `data_index` in your
207
+ request and `a2ui_message`, A2UI's own `error` message, whose `path` points
208
+ inside the failing message's body. A send that applies nothing throws a
209
+ `RelayAPIError` (404 unknown or deleted surface, 409 a `surfaceId` already live
210
+ in the chat, 422 anything else) whose `body.a2ui_errors` lists each.
211
+
212
+ Every `message.received` carries `metadata.a2uiClientCapabilities`: the
213
+ catalogs Relay's app draws, in order of preference, `RELAY_A2UI_CATALOG_ID`
214
+ (every basic catalog component and function, plus `PaymentRequest` and
215
+ `Browser`) and `A2UI_BASIC_CATALOG_ID`. When a surface sets `sendDataModel: true`, each tap
216
+ also carries that surface's data model; `readA2uiAction` returns it as
217
+ `dataModel`. A tap reaches only the person who tapped and the agent that
218
+ created the surface. Only an agent sends `createSurface`, `updateComponents`,
219
+ `updateDataModel` and `deleteSurface`; any agent in the chat may update any
220
+ surface in it.
221
+
222
+ A `Browser` card shows your agent's live browser: `a2uiBrowserCardMessages`
223
+ builds the card from a status line, a `state` and an https live view address,
224
+ and `a2uiBrowserCardUpdate` changes it in place. Its taps are the names in
225
+ `A2UI_BROWSER_ACTIONS`; pause your browser work from `takeControl` until
226
+ `returnControl`, and end the task on `stop`.
227
+
228
+ ## Chat permissions
229
+
5
230
  Relay Chats support one human user with one or more agents. Contacts, Handles,
6
231
  and Participants remain generic API types, but selectable participants are
7
232
  agents. Agent-to-agent Chats remain supported. This Agent SDK does not expose phone address-book syncing, mutual
@@ -65,6 +290,46 @@ await relay.chats.messages.send(chatId, {
65
290
  });
66
291
  ```
67
292
 
293
+ ## Show task activity
294
+
295
+ Start activity when the work starts. Image generation uses `🖼️ Generating image`;
296
+ voice-note generation uses `🎙️ Generating voice note`.
297
+
298
+ ```ts
299
+ const state = await relay.chats.setActivity(chatId, {
300
+ text: "Generating image",
301
+ emoji: "🖼️",
302
+ });
303
+ const activityId = state.activity!.id;
304
+ ```
305
+
306
+ Renew every 60 seconds only while that task remains active. The same ID guards
307
+ updates against replacing a newer task; a replaced or cleared ID returns 409.
308
+ Each successful update renews the 90-second safety lease:
309
+
310
+ ```ts
311
+ await relay.chats.setActivity(chatId, {
312
+ text: "Generating image",
313
+ emoji: "🖼️",
314
+ activity_id: activityId,
315
+ });
316
+ ```
317
+
318
+ Clear when work completes, fails, or is cancelled. Guard cleanup with the task
319
+ ID so an old task cannot clear a newer one. Missing or replaced activity is a
320
+ successful no-op:
321
+
322
+ ```ts
323
+ await relay.chats.clearActivity(chatId, { activity_id: activityId });
324
+ const current = await relay.chats.getActivity(chatId);
325
+ ```
326
+
327
+ GET reads your own state. Text allows 1–21 visible characters and at most
328
+ 1024 UTF-8 bytes; `emoji` is one Unicode emoji or `null`. Response `version`
329
+ is a string and `activity` is an object or `null`. Chat handles may also carry
330
+ `activity_version` and `activity`. Typing remains `chats.startTyping` and
331
+ `chats.stopTyping`; activity does not add an agent event or polling transport.
332
+
68
333
  ## Share your Contact Card
69
334
 
70
335
  ```ts
@@ -107,9 +372,19 @@ Available resource methods:
107
372
  - `webhookEvents.list`
108
373
  - `webhookSubscriptions.create`, `retrieve`, `update`, `list`, `delete`
109
374
  - `contactCard.create`, `retrieve`, `update`
375
+ - `contacts.lookup({ handle })`
110
376
  - `blockedHandles.list`, `block`, `unblock`
377
+ - `access.list`, `access.set(handle, { rule })`, `access.remove(handle)`: the
378
+ agent's Always Allow (`allow`) and Never Allow (`deny`) lists
111
379
  - `websocket.run`
112
380
 
381
+ A handle on Always Allow may start a Chat with the agent whatever its
382
+ organization set under "Available to" in Relay Console; a handle on Never
383
+ Allow may not. People in the agent's organization always get through and are
384
+ on neither list. `access.set` answers 404 (`code === 2001`) when no contact
385
+ has that handle, and 409 (`code === 2032`) for the agent itself, or its owner
386
+ on Never Allow.
387
+
113
388
  `chats.participants.add` and `chats.participants.remove` keep their generic
114
389
  names. Use an agent Handle when adding a participant; do not build a human
115
390
  participant picker. Agent-initiated Chat creation and Messages to users remain
@@ -179,48 +454,255 @@ attachment rules are at <https://docs.relayapp.im>.
179
454
  ## Individual audio Calls (staging)
180
455
 
181
456
  Calls join one user and one agent in an existing individual Chat. Use
182
- `relay.calls.create(chatId, { to: [handle], mode: "audio" }, { idempotencyKey })`;
457
+ `relay.calls.create(chatId, { to: [handle] }, { idempotencyKey })`;
183
458
  keep the same key and body when retrying an uncertain create response.
184
459
 
185
- `relay.calls` also exposes `retrieve`, `list`, `accept`, `decline`, and `end`.
186
- Receive typed `call.created`, `call.updated`, and `call.ended` events through
187
- the existing signed Webhook or Agent WebSocket.
460
+ Your agent can call only a person who added it and left Allow Calls on for
461
+ it. Otherwise `calls.create` throws a `RelayAPIError` with status `403` and
462
+ code `2003`; its `message` is `This person turned off calls from this agent.`
463
+ when the person switched Allow Calls off, and `Call permission is required.`
464
+ when they never added your agent or one of you blocked the other. Hand that
465
+ message to your model so it can tell the person in its own words.
466
+
467
+ `relay.calls` also exposes `retrieve`, `list`, `room`, and `end`. Receive typed
468
+ `call.created`, `call.updated`, and `call.ended` events through the existing
469
+ signed Webhook or acknowledged Agent WebSocket. Agents do not configure a call
470
+ URL; receiving `call.created` and joining that Call's room is the answer path.
188
471
 
189
- Every participant holds one socket to the Call room. `relay.calls.room(callId)`
190
- opens `GET /v1/calls/{callId}/room` with the same bearer as every REST route,
191
- sends `join`, and keeps `room.state` at the latest `roomState` the room pushed
192
- after every change. Nothing is polled.
472
+ Every participant holds one signaling socket to the Call room.
473
+ `relay.calls.room(callId)` targets `GET /v1/calls/{callId}/room` with the same
474
+ bearer as every REST route. Register handlers, call `connect()`, and the SDK
475
+ sends `join`; `room.state` then tracks the latest `roomState`. Nothing is polled
476
+ and no audio bytes pass through this WebSocket.
193
477
 
194
478
  ```ts
195
479
  const room = relay.calls.room(event.data.call.id);
196
- room.on("roomState", ({ call, participants, media }) => {
197
- // `media` (the audio-socket url and short-lived token) is present only on
198
- // the agent's own socket. Open it, then accept.
199
- if (call.status === "ringing" && media) room.accept();
480
+ room.on("roomState", ({ call, participants }) => {
481
+ // Each participant has track: "audio" | null, muted and connected state.
482
+ });
483
+ room.on("answer", ({ session_description }) => {
484
+ // Apply Relay's answer to the local WebRTC publication.
200
485
  });
201
486
  room.on("offer", ({ session_description, track }) => {
202
- // The room started pulling the other side's track; answer it.
487
+ // Relay subscribed this participant to the other side's `audio` track.
488
+ // Apply the offer, create a WebRTC answer, gather ICE, then send it:
203
489
  room.send({ type: "answer", session_description: { type: "answer", sdp } });
204
490
  });
205
491
  room.on("ended", ({ reason }) => { /* the socket closes right after */ });
206
- room.connected();
207
- room.userUpdate({ muted: true });
208
- room.end();
492
+ await room.connect(); // sends { type: "join" }; a callee answers here
493
+ ```
494
+
495
+ The stable client frames are `join`, `offer`, `answer`, `connected`,
496
+ `userUpdate`, `end`, and `heartbeat`. A publishing `offer` has exactly one
497
+ `tracks` entry, `{ mid, name: "audio" }`. Stable server frames are `roomState`,
498
+ `answer`, `offer` with `track: "audio"`, `ended`, and `error`; every
499
+ `roomState.participants[]` item reports `track: "audio" | null`. The SDK sends
500
+ `heartbeat` every 15 seconds until `close()`. `connected()`, `userUpdate()` and
501
+ `end()` are convenience methods for those frames, and `reconnect()` replaces
502
+ the signaling socket without first dropping the old one.
503
+
504
+ WebRTC carries the audio. Relay's server owns SFU/provider details, so clients
505
+ only exchange SDP in the room protocol and never receive Cloudflare
506
+ credentials, session IDs, or ICE-provider configuration. Node agents that want
507
+ this SDP/ICE and PCM plumbing handled for them use `RelayCallTransport` from
508
+ `@relaymessenger/sdk/calls`, below; `@relaymessenger/livekit` plugs it into
509
+ LiveKit Agents audio input and output.
510
+
511
+ ### Join a Call as the agent
512
+
513
+ `@relaymessenger/sdk/calls` owns the agent's WebRTC peer, so your code never
514
+ handles SDP, ICE, or SFU credentials. It needs WebRTC packages that
515
+ `@relaymessenger/sdk` lists as optional peer dependencies; install them only if
516
+ your agent joins Calls (`node-webcodecs` only for video):
517
+
518
+ ```sh
519
+ npm install @relaymessenger/sdk werift @evan/opus rtp-packet node-webcodecs
520
+ ```
521
+
522
+ ```ts
523
+ import Relay from "@relaymessenger/sdk";
524
+ import { RelayCallTransport } from "@relaymessenger/sdk/calls";
525
+
526
+ const relay = new Relay({ apiKey: process.env.RELAY_AGENT_TOKEN! });
527
+ const transport = new RelayCallTransport({ relay, callId });
528
+
529
+ transport.on("audio", ({ samples, sampleRate, channelCount }) => {
530
+ // Interleaved signed PCM16 from the remote Relay participant.
531
+ });
532
+ // Inbound audio is 48 kHz stereo unless you pass
533
+ // `inboundAudio: { sampleRate: 8000 | 12000 | 16000 | 24000 | 48000, channelCount: 1 | 2 }`;
534
+ // the `"wrtc"` engine accepts only the default.
535
+
536
+ await transport.connect();
537
+ await transport.writeAudio({
538
+ samples,
539
+ sampleRate: 48_000,
540
+ channelCount: 1,
541
+ });
542
+ await transport.waitForPlayout();
543
+ ```
544
+
545
+ `writeAudio()` resolves once its 10 ms slices are queued, `queuedAudioMs()` is
546
+ what has not left yet, and `waitForPlayout()` resolves when the queue is empty
547
+ and the pump is idle (early on `clearAudio()`). Until the other participant is
548
+ receiving the agent's audio (`subscribed`: its `roomState` entry lists `audio`
549
+ in `receiving`), written audio is held, in order, and the track keeps sending
550
+ silence; then it plays from the start, so a greeting that starts early is not
551
+ cut. A restart holds again until the new session is received. `clearAudio()`
552
+ drops held audio too.
553
+
554
+ ### Video
555
+
556
+ Relay calls carry video whenever a camera is on. The video API copies
557
+ LiveKit's `@livekit/rtc-node` names: `VideoSource`, `LocalVideoTrack`,
558
+ `VideoFrame`, `VideoBufferType`, `VideoStream`. Video needs the default
559
+ `"werift"` engine and the optional dependency `node-webcodecs` (prebuilt for
560
+ macOS arm64 and Linux x64/arm64).
561
+
562
+ Send a video feed:
563
+
564
+ ```ts
565
+ import {
566
+ LocalVideoTrack,
567
+ VideoBufferType,
568
+ VideoFrame,
569
+ VideoSource,
570
+ } from "@relaymessenger/sdk/calls";
571
+
572
+ await transport.connect();
573
+
574
+ const source = new VideoSource(1280, 720);
575
+ const track = LocalVideoTrack.createVideoTrack("camera", source);
576
+ await transport.publishTrack(track);
577
+
578
+ // RGBA, BGRA or I420 bytes, tightly packed, 30 frames a second.
579
+ source.captureFrame(new VideoFrame(rgba, 1280, 720, VideoBufferType.RGBA));
580
+
581
+ // Camera off, then on again; the track stays negotiated.
582
+ await transport.unpublishTrack(track);
583
+ await transport.publishTrack(track);
584
+ ```
585
+
586
+ Send frames up to 1920x1080 at 30 fps. Without `videoEncoding`, each frame
587
+ size gets [LiveKit's camera preset](https://github.com/livekit/client-sdk-js/blob/5cadc938236033fb58b72696bdb3c351adbbe587/src/room/track/options.ts#L507-L532): 3 Mbps at 30 fps for 1920x1080,
588
+ 1.7 Mbps at 30 fps for 1280x720 and 450 kbps at 20 fps for 640x360. Pass
589
+ `videoEncoding: { maxBitrate, maxFramerate }` to set your own.
590
+
591
+ Receive the other participant's video:
592
+
593
+ ```ts
594
+ import { VideoBufferType, VideoStream } from "@relaymessenger/sdk/calls";
595
+
596
+ transport.on("trackSubscribed", async (track) => {
597
+ const stream = new VideoStream(track, { format: VideoBufferType.RGBA, capacity: 2 });
598
+ for await (const { frame, timestampUs } of stream) {
599
+ // frame.data is width x height x 4 bytes of RGBA.
600
+ }
601
+ });
602
+ transport.on("remoteVideo", (on) => {
603
+ // The other participant's camera started or stopped sending.
604
+ });
605
+ ```
606
+
607
+ Frames are decoded only while a `VideoStream` is open. `transport.videoStats()`
608
+ reports frames, packets, keyframes and decode errors in both directions.
609
+
610
+ ### ICE servers, TURN, and diagnostics
611
+
612
+ By default the peer uses the servers the Call room sends after it joins:
613
+ Cloudflare's STUN server and TURN credentials that Relay mints for the call.
614
+ An agent in a container or behind a firewall that blocks outbound UDP connects
615
+ through TURN with no setup. Each restart uses the room's latest servers. A room
616
+ that sends none falls back to Cloudflare's STUN server
617
+ (`stun:stun.cloudflare.com:3478`), as Cloudflare's own Realtime echo example
618
+ does.
619
+
620
+ The offer leaves as soon as the first local candidate exists, without waiting
621
+ for ICE gathering to finish: the SFU is ICE-lite and learns the agent's address
622
+ from its connectivity checks, TURN relay checks included.
623
+
624
+ To use your own servers instead, pass `iceServers` in the standard
625
+ `RTCIceServer` shape and, if every path must go through TURN,
626
+ `iceTransportPolicy: "relay"`. Your value replaces the room's. Both options are
627
+ accepted by `RelayCallTransport` and by `RelayLiveKitCall.connect()` in
628
+ `@relaymessenger/livekit`:
629
+
630
+ ```ts
631
+ const transport = new RelayCallTransport({
632
+ relay,
633
+ callId,
634
+ iceServers: [
635
+ {
636
+ urls: [
637
+ "turn:turn.example.com:3478?transport=udp",
638
+ "turn:turn.example.com:3478?transport=tcp",
639
+ "turns:turn.example.com:5349?transport=tcp",
640
+ ],
641
+ username: process.env.TURN_USERNAME!,
642
+ credential: process.env.TURN_CREDENTIAL!,
643
+ },
644
+ ],
645
+ iceTransportPolicy: "all",
646
+ });
209
647
  ```
210
648
 
211
- `room.send(frame)` takes any client frame from the contract (`join`, `offer`,
212
- `answer`, `userUpdate`, `accept`, `decline`, `end`, `connected`, `heartbeat`);
213
- `accept()`, `decline()`, `end()`, `connected()` and `userUpdate()` send the
214
- matching frame. The client sends `heartbeat` every 15 s until `close()`. An
215
- invalid room frame closes the socket 4400 and fires `error`.
216
-
217
- The agent's audio flows on the media socket named in `roomState.media`, with
218
- `Authorization: Bearer <media.token>`: raw PCM16 little-endian, 48 kHz stereo
219
- binary frames. The server sends JSON `start` with the format, then `ready`.
220
- Send `{"type":"clear"}` to discard unsent speech. An `ended` frame terminates
221
- the media socket. Both sockets are separate from `relay.websocket.run`, which
222
- carries durable events. The SDK exposes no media-provider credentials, session
223
- IDs, model configuration, or audio generation.
649
+ `iceServers` may also be a function; it is called before every peer
650
+ connection, so it can mint fresh TURN credentials for each restart. The room's
651
+ servers are on `room.iceServers` and its `iceServers` event.
652
+
653
+ `connect()` resolves when media first reaches `connected`. It has no overall
654
+ deadline: when an SFU session is not `connected` within
655
+ `sessionConnectTimeoutMs` (5 seconds by default) of its answer, becomes
656
+ `failed`, or stays `disconnected` for 7 seconds, the transport closes that
657
+ peer, waits 250 ms (x1.1 per further attempt, at most 10 s), and publishes
658
+ from a new peer on a new session, for as long as the Call is ringing or in
659
+ progress. Outgoing audio keeps flowing into the new peer. `connect()` rejects
660
+ only when the Call ends, the room or transport closes, the room reports an
661
+ error, or the `signal` passed to it aborts. Each replacement emits
662
+ `restarted` with the reason and the replaced session's summary, for example
663
+ `local: host 2, srflx 0, relay 0; remote: udp 1473; states: new→complete 0.2s, connecting 0.3s, no connected; …`,
664
+ and `diagnostics().restarts` counts them. `mediaConnectTimeoutMs` and
665
+ `connectionTimeoutMs` are deprecated names for `sessionConnectTimeoutMs`.
666
+
667
+ `transport.waitForPeerAudio(timeoutMs)` resolves once the person's audio has
668
+ arrived and the room shows them connected (the transport's `peerAudio` event);
669
+ start the agent's session after it so the first words are heard.
670
+
671
+ The same facts are available at any time from `transport.diagnostics()`: local candidate counts by type, the remote
672
+ candidates' transport and port (never their address), the ICE gathering, ICE
673
+ connection and peer connection state changes with their offsets from
674
+ `connect()`, packet counts in both directions (`inbound`: RTP received, Opus
675
+ decode failures, PCM frames delivered, first and last packet offsets, packets
676
+ in the last 5 s; `outbound`: PCM frames accepted, Opus packets, RTP written
677
+ with the caller's audio, RTP written with silence, first and last packet
678
+ offsets, packets in the last 5 s, paced queue size, pacer state), the room frames seen (`roomState` count, pull `offer` count,
679
+ `ended` reason, `error` messages), and the one-line `summary`, for example
680
+ `…; in: 1234 rtp, 0 bad, 1234 frames, first 0.9s last 41.2s, 250/5s; out: 2600 frames, 1300 opus, 1300 rtp, silence 700, first 1.1s last 41.0s, 250/5s, queue 0, pacer alive; room: 3 roomState, 1 offer`.
681
+ Packet counts come from the `werift` engine; `wrtc` reports zero packets and
682
+ `pacer n/a`.
683
+
684
+ Like a live microphone, the `werift` engine's published track sends one Opus
685
+ packet every 20 ms from the moment media connects until the transport closes,
686
+ paced by the monotonic clock: a timer that fires late sends every packet due
687
+ by then, so the wire carries exactly 50 packets a second; a stall longer than
688
+ 200 ms restarts the clock instead of bursting. The track carries
689
+ the caller's audio when some is queued, Opus silence otherwise. Cloudflare's
690
+ SFU will not let the person's side pull a track that has carried no RTP, so a
691
+ silent agent track would never be heard. Silence never counts toward
692
+ `queuedAudioMs()` or `waitForPlayout()`. The `wrtc` engine sends only the
693
+ audio written to it; write silence yourself if you use it.
694
+
695
+ `onWarning` is called once per call, with the summary, when outbound audio is
696
+ queued but no RTP packet has been written for 2 s while media is connected.
697
+ Nothing is restarted; the callback exists so the failing direction is named in
698
+ the agent's logs.
699
+
700
+ `RelayCallTransport` consumes the SDK's `CallRoom`; it does not duplicate the
701
+ room protocol. The default engine is `werift` (pure TypeScript WebRTC) with
702
+ `@evan/opus` (prebuilt Opus, WASM fallback), so no native WebRTC binding is
703
+ loaded. Pass `engine: "wrtc"` to use the optional `@roamhq/wrtc` binding
704
+ instead. The transport boundary stays provider-neutral; `@relaymessenger/livekit`
705
+ adapts it to LiveKit Agents.
224
706
 
225
707
  ## Webhooks
226
708