@relaymessenger/chat-sdk-adapter 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Companion Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # @relaymessenger/chat-sdk-adapter
2
+
3
+ Relay adapter for the [Vercel Chat SDK](https://chat-sdk.dev). Relay is a
4
+ consumer messenger where people talk to agents the way they talk to contacts;
5
+ this package makes a Relay conversation a Chat SDK thread, so anything built on
6
+ the Chat SDK can reach Relay users. Raw HTTPS remains the canonical contract;
7
+ this is a thin, dependency-free binding of it.
8
+
9
+ ```ts
10
+ import { createMemoryState } from "@chat-adapter/state-memory";
11
+ import { createRelayAdapter } from "@relaymessenger/chat-sdk-adapter";
12
+ import { Chat } from "chat";
13
+
14
+ const chat = new Chat({
15
+ userName: "My Agent",
16
+ adapters: {
17
+ relay: createRelayAdapter({
18
+ token: process.env.RELAY_AGENT_TOKEN!,
19
+ webhookSecret: process.env.RELAY_WEBHOOK_SECRET!,
20
+ }),
21
+ },
22
+ state: createMemoryState(),
23
+ });
24
+
25
+ chat.onNewMention(async (thread, message) => {
26
+ await thread.subscribe();
27
+ await thread.post({ markdown: `You said: ${message.text}` });
28
+ });
29
+
30
+ // Mount as a POST route.
31
+ export const POST = (request: Request) => chat.webhooks.relay(request);
32
+ ```
33
+
34
+ `chat` is a peer dependency: install it alongside this package.
35
+
36
+ ## With eve
37
+
38
+ eve's Chat SDK channel bridges any adapter to an agent. See
39
+ [`examples/eve`](../../examples/eve) in this repository for the channel file and
40
+ the environment it needs.
41
+
42
+ ## What the adapter enforces
43
+
44
+ - Standard Webhooks signature verification over the exact raw body, then
45
+ `event_id` deduplication, because Relay delivers at least once. The event id
46
+ is claimed before the handler runs, so two redeliveries racing each other
47
+ cannot both dispatch, and released again if the handler throws, so Relay's
48
+ retry of a failed turn is still handled. **This window is a bounded set in
49
+ memory, in one process.** A restart, or a second instance behind the same
50
+ webhook URL, has no claim to lose and will dispatch the event again. The
51
+ idempotency key below is what makes that second dispatch harmless.
52
+ - A deterministic `Idempotency-Key` on every `POST /v1/messages`, derived from
53
+ the inbound event id and the send's position in the turn, and from nothing
54
+ else. Relay hashes the request body server side and stores it beside the key,
55
+ so a retry carrying the same body replays the first response and a retry
56
+ carrying a different body is refused with 409 `idempotency_conflict`. Keeping
57
+ the content out of the key is what leaves both of those reachable: a key that
58
+ moved with the body would make every retry a new send, and a handler backed
59
+ by a model rarely writes the same words twice.
60
+ - Group `invocation_id` threading. Relay delivers a group message to an agent
61
+ only when that agent was invoked, and the reply is scoped to that single-use
62
+ invocation, so the first send of a turn carries it and a second raises
63
+ `RelayInvocationSpentError` rather than going out bare and taking a 403.
64
+ - Chunking rather than truncation. Relay caps a text part at 8 KB and a message
65
+ at 32 parts; a longer reply becomes more parts, and then more messages. Each
66
+ text part draws as its own balloon in the app, so a long reply arrives as a
67
+ stack of bubbles rather than one tall one. A split consumes the whitespace it
68
+ lands on, and nothing else.
69
+
70
+ ## Formatting
71
+
72
+ Relay does not render Markdown. A text part carries canonical plain text plus
73
+ `styles` runs with UTF-16 offsets, and clients draw the runs. So `{ markdown }`
74
+ and `{ ast }` are flattened to the text a person reads, with emphasis carried
75
+ across as style ranges: `strong` becomes `bold`, `emphasis` becomes `italic`,
76
+ `delete` becomes `strikethrough`, and inline or fenced code becomes `monospace`.
77
+
78
+ Constructs Relay has no style for keep their information in the text rather than
79
+ losing it. A link whose label differs from its target renders as `label (url)`,
80
+ a blockquote keeps its `> ` line prefix, a list keeps its markers, and a table is
81
+ drawn as a monospace ASCII grid. A plain string is sent verbatim with an empty
82
+ `styles` array, which is Relay's marker for structured plain text rather than a
83
+ legacy Markdown body.
84
+
85
+ ## Capabilities
86
+
87
+ | Chat SDK operation | Relay |
88
+ | --------------------------------- | ---------------------------------------------------------------- |
89
+ | `postMessage`, `editMessage`, `deleteMessage` | `POST /v1/messages`, `PATCH` and `DELETE /v1/messages/{id}` |
90
+ | `addReaction`, `removeReaction` | `POST /v1/messages/{id}/reactions` |
91
+ | `startTyping` | `POST /v1/conversations/{id}/typing`, ephemeral, 80-char label |
92
+ | `markAsRead` | `POST /v1/conversations/{id}/read` |
93
+ | `fetchMessages`, `fetchThread` | `GET /v1/conversations/{id}/messages` and `/v1/conversations/{id}` |
94
+ | `getUser` | `GET /v1/users/{id}`, scoped to a shared conversation |
95
+ | `stream` | Buffered, then committed as one message |
96
+
97
+ Things Relay does not do, stated rather than faked:
98
+
99
+ - **No streaming bubbles.** A turn commits exactly one canonical message, so
100
+ `stream` buffers the whole reply and posts it once. Nothing partial reaches a
101
+ recipient. With eve's Chat SDK channel, set `streaming: false`.
102
+ - **No forward history cursor.** `GET /v1/conversations/{id}/messages` pages
103
+ backwards with `before_sequence`, so `fetchMessages({ direction: "forward" })`
104
+ throws `NotImplementedError` instead of walking the whole conversation.
105
+ - **No agent-initiated DM.** `POST /v1/conversations/direct` accepts a user
106
+ session, not an Agent Token, so `openDM` is not implemented. A conversation
107
+ starts when a person adds the agent.
108
+ - **No single-message read.** The API has no `GET /v1/messages/{id}`, so
109
+ `fetchMessage` is not implemented and the Chat SDK returns `null`.
110
+ - **No cards.** Relay removed interactive components. A card is delivered as its
111
+ fallback text, so the words arrive but buttons do not render and a
112
+ human-in-the-loop prompt cannot be answered in the app.
113
+ - **No edits carrying media.** Relay requires an edit to stay text-bearing and
114
+ rejects attachment parts.
115
+
116
+ Attachments work in both directions. Inbound media and voice memo parts arrive as
117
+ Chat SDK attachments; outbound attachments with a public HTTPS URL are sent as
118
+ media parts, and outbound bytes are uploaded through `POST /v1/attachments`
119
+ first.
120
+
121
+ Docs: <https://docs.relayapp.im>.
@@ -0,0 +1,125 @@
1
+ import { Message } from "chat";
2
+ import type { Adapter, AdapterPostableMessage, ChatInstance, EmojiValue, FetchOptions, FetchResult, FormattedContent, RawMessage, StreamChunk, ThreadInfo, UserInfo, WebhookOptions } from "chat";
3
+ import { RelayClient } from "./client.js";
4
+ import type { RelayClientOptions } from "./client.js";
5
+ import type { RelayMessage, RelayRawMessage, RelayThreadId } from "./types.js";
6
+ export declare const RELAY_ADAPTER_NAME = "relay";
7
+ export interface RelayAdapterOptions extends Omit<RelayClientOptions, "token"> {
8
+ /** Agent Token. Defaults to `RELAY_AGENT_TOKEN`. */
9
+ token?: string;
10
+ /** Webhook signing secret. Defaults to `RELAY_WEBHOOK_SECRET`. */
11
+ webhookSecret?: string;
12
+ /** Display name for the agent. Defaults to `Relay Agent`. */
13
+ userName?: string;
14
+ /** This agent's `agt_` id, when the caller knows it. */
15
+ agentId?: string;
16
+ /** Clock tolerance for signature verification, in seconds. */
17
+ toleranceSeconds?: number;
18
+ /** How many handled `event_id` values to remember. */
19
+ dedupeWindow?: number;
20
+ /** Override the client, mainly for tests. */
21
+ client?: RelayClient;
22
+ }
23
+ /**
24
+ * A group turn tried to commit a second message. Relay's invocation is single
25
+ * use, so there is nothing valid for the second message to cite and the server
26
+ * would answer 403.
27
+ */
28
+ export declare class RelayInvocationSpentError extends Error {
29
+ constructor(message: string);
30
+ }
31
+ /**
32
+ * Relay adapter for the Vercel Chat SDK.
33
+ *
34
+ * Relay is a consumer messenger where people talk to agents as contacts, so
35
+ * this adapter maps the Chat SDK's thread model onto Relay conversations one
36
+ * to one: a Relay conversation has no enclosing channel, and a thread id is
37
+ * `relay:{conversation_id}`.
38
+ *
39
+ * Two Relay rules shape the surface. A streamed turn commits exactly one
40
+ * canonical message, so `stream` buffers and posts once rather than editing a
41
+ * draft bubble into place. And a group reply is scoped to the single-use
42
+ * invocation that produced the inbound event, so the first send of a turn
43
+ * carries it and a second cannot.
44
+ */
45
+ export declare class RelayAdapter implements Adapter<RelayThreadId, RelayRawMessage> {
46
+ readonly name = "relay";
47
+ readonly userName: string;
48
+ readonly botUserId?: string;
49
+ readonly lockScope: "thread";
50
+ /** Relay serves history from `GET /v1/conversations/{id}/messages`. */
51
+ readonly persistThreadHistory = false;
52
+ private readonly client;
53
+ private readonly webhookSecret?;
54
+ private readonly toleranceSeconds?;
55
+ private readonly dedupe;
56
+ private chat?;
57
+ constructor(options?: RelayAdapterOptions);
58
+ initialize(chat: ChatInstance): Promise<void>;
59
+ encodeThreadId(platformData: RelayThreadId): string;
60
+ decodeThreadId(threadId: string): RelayThreadId;
61
+ channelIdFromThreadId(threadId: string): string;
62
+ renderFormatted(content: FormattedContent): string;
63
+ parseMessage(raw: RelayRawMessage): Message<RelayRawMessage>;
64
+ postMessage(threadId: string, message: AdapterPostableMessage): Promise<RawMessage<RelayRawMessage>>;
65
+ editMessage(threadId: string, messageId: string, message: AdapterPostableMessage): Promise<RawMessage<RelayRawMessage>>;
66
+ deleteMessage(threadId: string, messageId: string): Promise<void>;
67
+ addReaction(threadId: string, messageId: string, emoji: EmojiValue | string): Promise<void>;
68
+ removeReaction(threadId: string, messageId: string, emoji: EmojiValue | string): Promise<void>;
69
+ /**
70
+ * Relay's typing indicator is ephemeral and carries an optional label of up
71
+ * to 80 characters. The invocation is peeked rather than consumed: typing is
72
+ * not the group reply the invocation is spent on.
73
+ *
74
+ * Failures are swallowed. Group typing without a live pending invocation is a
75
+ * 403 (`Relay-Server/server/src/domain/typing.ts:70-73`), which is exactly
76
+ * what typing after the first send of a group turn looks like. The Chat SDK
77
+ * treats this call as best effort and has no `stopTyping` to strand, so a
78
+ * hint that cannot be shown must never take the reply down with it.
79
+ */
80
+ startTyping(threadId: string, status?: string): Promise<void>;
81
+ markAsRead(threadId: string, messageId: string): Promise<void>;
82
+ getUser(userId: string): Promise<UserInfo | null>;
83
+ /**
84
+ * Relay pages history backwards with `before_sequence` and returns newest
85
+ * first; the Chat SDK wants each page in chronological order. There is no
86
+ * forward cursor on the route, so `direction: "forward"` has nothing to call.
87
+ */
88
+ fetchMessages(threadId: string, options?: FetchOptions): Promise<FetchResult<RelayRawMessage>>;
89
+ fetchThread(threadId: string): Promise<ThreadInfo>;
90
+ /**
91
+ * Relay commits one canonical message per turn and has no draft bubble to
92
+ * edit, so the stream is buffered and posted once. Nothing partial ever
93
+ * reaches a recipient, and no cleanup is needed if the stream fails midway.
94
+ */
95
+ stream(threadId: string, textStream: AsyncIterable<string | StreamChunk>): Promise<RawMessage<RelayRawMessage> | null>;
96
+ /**
97
+ * Verify the Standard Webhooks signature over the exact raw body, refuse a
98
+ * replay by `event_id`, and hand the event to the Chat SDK. Relay redelivers
99
+ * on 5xx, so a dispatch failure must not answer 2xx.
100
+ */
101
+ handleWebhook(request: Request, options?: WebhookOptions): Promise<Response>;
102
+ private dispatch;
103
+ private buildParts;
104
+ private textParts;
105
+ /**
106
+ * Relay has no card surface: interactive components were removed from the
107
+ * app, so buttons cannot render and a human cannot answer one in Relay. The
108
+ * card's words are still delivered, as text, rather than dropped.
109
+ */
110
+ private cardToText;
111
+ private attachmentPart;
112
+ private filePart;
113
+ /**
114
+ * Send the parts in one call when they fit, and as follow-up calls when
115
+ * they do not. The server splits each call at ingest into one or more
116
+ * messages, and the one call that carries the invocation owns every message
117
+ * it commits. A group turn cannot overflow, and cannot POST twice: Relay's
118
+ * invocation is single use per call, so a second POST has nothing valid to
119
+ * cite and the server answers 403.
120
+ */
121
+ private sendParts;
122
+ }
123
+ /** Build a Relay adapter for the Vercel Chat SDK. */
124
+ export declare function createRelayAdapter(options?: RelayAdapterOptions): RelayAdapter;
125
+ export type { RelayMessage };