@relaymessenger/sdk 0.3.6-staging.9 → 0.3.7-staging.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.
- package/README.md +516 -29
- package/dist/a2ui.d.ts +111 -0
- package/dist/a2ui.d.ts.map +1 -0
- package/dist/a2ui.js +137 -0
- package/dist/a2ui.js.map +1 -0
- package/dist/calls/call-room.d.ts +110 -0
- package/dist/calls/call-room.d.ts.map +1 -0
- package/dist/calls/call-room.js +616 -0
- package/dist/calls/call-room.js.map +1 -0
- package/dist/calls/engine-werift-video.d.ts +83 -0
- package/dist/calls/engine-werift-video.d.ts.map +1 -0
- package/dist/calls/engine-werift-video.js +607 -0
- package/dist/calls/engine-werift-video.js.map +1 -0
- package/dist/calls/engine-werift.d.ts +48 -0
- package/dist/calls/engine-werift.d.ts.map +1 -0
- package/dist/calls/engine-werift.js +462 -0
- package/dist/calls/engine-werift.js.map +1 -0
- package/dist/calls/index.d.ts +11 -0
- package/dist/calls/index.d.ts.map +1 -0
- package/dist/calls/index.js +11 -0
- package/dist/calls/index.js.map +1 -0
- package/dist/calls/transport.d.ts +466 -0
- package/dist/calls/transport.d.ts.map +1 -0
- package/dist/calls/transport.js +1409 -0
- package/dist/calls/transport.js.map +1 -0
- package/dist/calls/video-frame.d.ts +56 -0
- package/dist/calls/video-frame.d.ts.map +1 -0
- package/dist/calls/video-frame.js +245 -0
- package/dist/calls/video-frame.js.map +1 -0
- package/dist/calls/video-presets.d.ts +39 -0
- package/dist/calls/video-presets.d.ts.map +1 -0
- package/dist/calls/video-presets.js +40 -0
- package/dist/calls/video-presets.js.map +1 -0
- package/dist/calls/video-rtp.d.ts +51 -0
- package/dist/calls/video-rtp.d.ts.map +1 -0
- package/dist/calls/video-rtp.js +294 -0
- package/dist/calls/video-rtp.js.map +1 -0
- package/dist/calls/video.d.ts +195 -0
- package/dist/calls/video.d.ts.map +1 -0
- package/dist/calls/video.js +228 -0
- package/dist/calls/video.js.map +1 -0
- package/dist/client.d.ts +199 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +444 -0
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +16 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/links.d.ts +17 -6
- package/dist/links.d.ts.map +1 -1
- package/dist/links.js +35 -4
- package/dist/links.js.map +1 -1
- package/dist/operations.d.ts +97 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +129 -0
- package/dist/operations.js.map +1 -1
- package/dist/payment.d.ts +67 -0
- package/dist/payment.d.ts.map +1 -0
- package/dist/payment.js +147 -0
- package/dist/payment.js.map +1 -0
- package/dist/reply-target.d.ts +26 -0
- package/dist/reply-target.d.ts.map +1 -0
- package/dist/reply-target.js +59 -0
- package/dist/reply-target.js.map +1 -0
- package/dist/selection.d.ts +46 -0
- package/dist/selection.d.ts.map +1 -0
- package/dist/selection.js +142 -0
- package/dist/selection.js.map +1 -0
- package/dist/types.d.ts +1113 -22
- package/dist/types.d.ts.map +1 -1
- package/dist/websocket.d.ts +6 -0
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +36 -10
- package/dist/websocket.js.map +1 -1
- package/package.json +37 -2
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]
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
//
|
|
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.
|
|
207
|
-
|
|
208
|
-
|
|
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();
|
|
209
543
|
```
|
|
210
544
|
|
|
211
|
-
`
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
});
|
|
647
|
+
```
|
|
216
648
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
|
@@ -284,7 +766,12 @@ last subscription makes the WebSocket path available again. Relay retains
|
|
|
284
766
|
undelivered events across either change.
|
|
285
767
|
|
|
286
768
|
The SDK derives `wss://<Relay host>/v1/websocket` from `baseURL` and sends the
|
|
287
|
-
Agent Token in the WebSocket upgrade `Authorization` header.
|
|
769
|
+
Agent Token in the WebSocket upgrade `Authorization` header. It names every
|
|
770
|
+
event type in `RELAY_WEBHOOK_EVENT_TYPES` with `subscribed_events`: Relay sends
|
|
771
|
+
a connection only the types it names, and the 16 original message, reaction,
|
|
772
|
+
participant, chat and contact types when it names none. An event type this
|
|
773
|
+
release does not know is skipped and acknowledged, and `onError` receives one
|
|
774
|
+
`RelayUnknownEventTypeError` for it.
|
|
288
775
|
|
|
289
776
|
The SDK validates the ready checkpoint, rejects sequence gaps, and routes
|
|
290
777
|
replayed sequences through your durable deduplication handler. It sends a
|