@relaymessenger/chat-sdk-adapter
v0.3.9
Published
Vendor-official Relay adapter for Vercel Chat SDK.
Maintainers
Readme
@relaymessenger/chat-sdk-adapter
Vendor-official Relay adapter for
Vercel Chat SDK, targeting [email protected].
Source is maintained in
RelayMessenger/Relay-SDK
under packages/chat-sdk-adapter. That link is pinned to a commit rather than
a branch, as the Chat SDK listing guide requires, so it keeps showing the tree
a listing was reviewed against. Re-pin it whenever the listing is updated.
SOURCE.json in this directory records where the code was imported from, not
which repository owns it. Every package in this monorepo carries the same
record, and its canonical field names Relay-SDK -- the same repository
package.json points at.
Relay Chats map one-to-one to Chat SDK threads. Provider thread IDs are stable
relay:<chat UUID> values; provider message IDs are bare Relay Message UUIDs.
Selection
Send native text/selection parts through postMessageParts using the existing
idempotency strategy. A selection carries its question in title (1 to 60
characters); a text part is optional and shows as a normal message above the
card. message.text retains the readable reply;
message.raw.message.parts and message.raw.message.reply_to retain metadata
through webhook ingress and history.
New human reply text is literal • + each selected source label joined with
\n, followed by selection_response metadata in source-option order. Dispatch
with selected_values and the explicit source target, never label parsing.
Exact legacy comma-joined text remains a server compatibility input. The person
checks any number of options (exactly one when multiple is false) and submits
them once; checking sends nothing, and
a person answers a given selection once. iOS may draw a checkmark in place of
each bullet and repeat the prompt's title, as presentation only.
Payment
An agent asks someone to pay in two steps. First create a payment request on
your organization's connected Stripe account with
adapter.client.createPaymentRequest({ amount, currency, description, category })
(category is physical_goods, digital_goods or donation). Then send its
checkout_url unchanged as a payment part through postMessageParts, on the
same idempotency lane as other posts. The payment must be the only part of its
Message, so send any words first with postMessage. A read-back payment
carries the request's fields and its status in message.raw.message.parts.
The status moves only on Stripe's word or your own
adapter.client.cancelPaymentRequest(id); your agent gets
payment.succeeded, payment.canceled or payment.expired, and a paid
request adds a payment_receipt message from the payer. Both parts reach
message.text as one line, for example Paid $24.00 for House blend, 250 g
or Payment request: $24.00 for House blend, 250 g (requested).
Install
npm install [email protected] @chat-adapter/[email protected] \
@relaymessenger/chat-sdk-adapterMinimal use
import { createMemoryState } from "@chat-adapter/state-memory";
import { createRelayAdapter } from "@relaymessenger/chat-sdk-adapter";
import { Chat } from "chat";
const chat = new Chat({
userName: "My Relay Agent",
adapters: {
relay: createRelayAdapter({
token: process.env.RELAY_AGENT_TOKEN,
webhookSecret: process.env.RELAY_WEBHOOK_SECRET,
}),
},
state: createMemoryState(),
});
chat.onNewMention(async (thread, message) => {
await thread.subscribe();
await thread.post(`You said: ${message.text}`);
});
// Mount this on the Relay webhook URL.
export const POST = (request: Request) => chat.webhooks.relay(request);An executable Node HTTP example is in examples/server.mjs.
Factory API
type RelayCredential =
| string
| (() => string | Promise<string>);
interface RelayAdapterOptions {
token?: RelayCredential; // RELAY_AGENT_TOKEN fallback
webhookSecret?: RelayCredential; // RELAY_WEBHOOK_SECRET fallback
typing?: boolean; // default true
userName?: string; // default "Relay Agent"
agentId?: string; // this agent's Relay Contact UUID
baseUrl?: string; // default https://api.relayapp.im
fetch?: typeof fetch;
client?: RelayClient;
signatureToleranceSeconds?: number; // default 300
markReadOnReceipt?: boolean; // default false
abortActiveTurnOnReceipt?: boolean; // default false
idempotencyKeyResolver?: (context: {
chatId: string;
threadId: string;
parts: readonly RelayOutgoingPart[];
replyToMessageId?: string;
}) => string | Promise<string>;
}
createRelayAdapter(options?: RelayAdapterOptions): RelayAdapter;Credential functions satisfy Vercel's vendor-official non-static credential requirement. The token resolver is called for every Relay API request. The webhook-secret resolver is called for every webhook delivery. Values are not cached.
Every send caused by an inbound webhook carries
Idempotency-Key: relay-chat-sdk:<event_id>:<send ordinal>. A redelivery starts
at ordinal zero again, so the same event/body replays while changed recovery
content reaches Relay under the same key and receives the contract's 409
idempotency_conflict. This context uses AsyncLocalStorage only for the
active turn and is never persisted.
Posts made outside an inbound webhook have no Relay event_id. The adapter
therefore requires idempotencyKeyResolver for those non-empty posts rather
than manufacturing a random key that changes on recovery. Think integrations
should return their stable Action/delivery identity from this resolver.
Read on receipt
markReadOnReceipt: true stamps POST /v1/chats/{id}/read for an inbound
message.received as soon as its signature verifies, before the event reaches
Chat SDK dispatch. A read receipt states that the message arrived, not that the
answer is ready, so it must not wait behind a debounce window or a model turn.
Turn it on whenever concurrency defers the handler.
Only a real inbound message stamps a read. The agent's own outbound messages
and the message.sent, message.delivered and message.read receipts do not.
A failed read is logged and never blocks the delivery's 2xx, because holding
the response open to retry a receipt costs a redelivery of the whole event.
Group messages are read too, including ones the agent stays silent on, and there is no flag to change that. Two reasons. Mention detection happens in Chat SDK dispatch, which is the very thing read-on-receipt gets ahead of, so at receipt the adapter cannot yet know whether it will answer. And it costs a human nothing: Relay renders only Delivered in a group and never a member's Read, so an agent's read in a group is not visible to anyone.
Abort on receipt
abortActiveTurnOnReceipt: true calls ChatInstance.abortTurn(threadId) when
a newer inbound message arrives, before the new event reaches dispatch. A
person who sends again while the agent is answering has changed the question,
so the running turn's thread.signal fires and the deferring concurrency
strategy hands the newer message to a fresh turn.
Cancellation crosses processes: this adapter sets supportsTurnCancellation,
so Chat publishes the active turn to the state adapter and a turn running in
another isolate stops when it next polls. A failed abort is logged and the
newer message is still dispatched; the running turn then finishes normally,
which is the behaviour of an agent without the option.
Concurrency strategies do not change this. burst, debounce and queue
defer the handler, but they await it inside the webhook call that carried the
message, so the turn is still in scope and thread.post() gets its key. The
test suite asserts that for all three, because the day a strategy resumes a
handler out of band is the day replies would start being refused.
Think runtime typing
For a runtime that owns typing timing, disable Chat SDK surface typing:
const relay = createRelayAdapter({
token,
webhookSecret,
typing: false,
});With typing: false, both startTyping() and endTyping() validate the thread
ID but make no Relay request. This prevents Think's pre-inference
ChatThread.startTyping() from surfacing. Other adapter instances default to
normal POST/DELETE /v1/chats/{chatId}/typing support.
Locked contract
This package was rewritten against:
- Relay Server
8247505bd5f8dffccf8047b91317a68a91632068 - OpenAPI SHA-256
f1d3f19b12e068ad68b95b41650b62af6f921ec263e37dd2d24f59a72903ce30 - public
ChatHandle.image_urlandChatHandle.aboutfields, with no legacy aliases - Relay API
v1 - Relay webhook payload version
2026-08-30 [email protected]
The byte-identical Server OpenAPI copy is retained under contracts/ for
reproducible contract tests and is excluded from the npm package.
Supported surface
| Chat SDK operation | Locked Relay v1 operation |
| --- | --- |
| postMessage, postChannelMessage | POST /v1/chats/{chatId}/messages |
| reply | Same route with message.reply_to |
| stream | Buffered, then one canonical Message; never partial bubbles |
| outbound public-URL media | Message media part |
| outbound bytes/files | POST /v1/attachments allocate, upload, then a Message media part |
| inbound media | Chat SDK Attachment with fetchData() |
| inbound reply (reply_to) | Chat SDK message.replyTo, read with GET /v1/messages/{messageId} |
| addReaction, removeReaction | POST /v1/messages/{messageId}/reactions |
| startTyping, endTyping | POST/DELETE /v1/chats/{chatId}/typing |
| markAsRead | POST /v1/chats/{chatId}/read |
| fetchMessages() (backward, the default) | Forward walk to the tail over GET /v1/chats/{chatId}/messages |
| fetchMessages({ direction: "forward" }) | One GET /v1/chats/{chatId}/messages |
| fetchMessage | GET /v1/messages/{messageId} |
| fetchThread, fetchChannelInfo | GET /v1/chats/{chatId} |
Inbound replies
When a person swipe-replies to a Message, the webhook carries only a pointer,
reply_to: { message_id, part_index }. The adapter reads that Message once
with GET /v1/messages/{messageId} and sets Chat SDK's own message.replyTo
to it, the way Chat SDK's Telegram adapter fills it from Telegram's
reply_to_message. When the target has more than one part, replyTo holds
only the part the person swiped. replyTo.author.isMe is true when the
person replied to your agent's own Message.
chat.onDirectMessage(async (thread, message) => {
const target = message.replyTo; // the Message this one answers, or undefined
});Chat SDK's toAiMessages does not render replyTo, and neither does Think.
Put it in the text your model reads for that turn, for example the way Hermes
Agent does: [Replying to your previous message: "…"] above the person's
text. A target that was deleted, or a read that fails, leaves replyTo
unset; a failed read is logged as relay_reply_target_failed and never
blocks the delivery.
Inbound attachments
An inbound Relay media part becomes a Chat SDK Attachment carrying
url, mimeType, name, size, type, width, height, and a
fetchData() that resolves to the bytes as an ArrayBuffer. Nothing is
downloaded until you call it.
for (const attachment of message.attachments) {
const bytes = await attachment.fetchData?.();
}A Relay download URL expires 60 minutes after Relay minted it. The URL is a
sealed, unauthenticated download capability, so fetchData() sends no Agent
Token; after that window the download fails with RelayApiError HTTP 404.
That expiry is not a limit on queued work. Message.toJSON() drops
fetchData, so queue and debounce strategies call rehydrateAttachment() to
rebuild it — and the rebuilt closure calls GET /v1/attachments/{attachmentId}
first, which mints a new 60-minute download link on every request. A Message may
sit in a queue for as long as you like and still read its bytes. The serialized
URL is kept in fetchMetadata only as the fallback for an attachment whose
metadata predates this behavior.
GET /v1/attachments/{attachmentId} authorizes any Chat participant who could
read the Message, so an agent reads the attachments of messages sent to it
without owning them.
Attachment content types
Relay accepts any syntactically valid type/subtype content type, at most 255
characters, and stores and returns the original bytes unchanged. There is no
allowlist. A declared type is lower-cased and its parameters are dropped; a
malformed one is refused before the request leaves your process. When nothing
is declared and the filename extension is unknown, the type is
application/octet-stream.
An inbound part becomes a Chat SDK attachment of type image, video or
audio from its type prefix, and file for everything else, so an unfamiliar
type arrives as a file rather than being dropped.
Only pictures and group icons must be images. Those are set with
@relaymessenger/sdk, never through this adapter. The current attachment
rules are at https://docs.relayapp.im.
Relay text parts are plain text and are limited to 10,000 UTF-16 code units. Long Chat SDK text is split without breaking surrogate pairs. A Relay Message is limited to 100 parts.
A public HTTPS attachment URL is sent by reference and costs no upload. Local
bytes -- files, or an attachment carrying data or fetchData -- are
allocated through POST /v1/attachments, uploaded, and then referenced by
attachment_id. Uploads finish before the send, so the Message body names
attachments that already exist.
One consequence is worth knowing. Inside an inbound webhook turn the send is
keyed on the event ID, so a webhook redelivery re-uploads the bytes, mints new
attachment IDs, and presents a different body under the same Idempotency-Key.
Relay answers HTTP 409 rather than posting the Message twice. A loud refusal on
redelivery is the safe end of that trade; a silent duplicate is not. To make a
file post survive redelivery unchanged, allocate and upload once with
@relaymessenger/sdk, retain that identity durably, and give this adapter the
stable HTTPS URL instead.
Think's thread.post(callback.stream()) path is safe when the stream is empty:
the adapter returns a local no-op result so Chat SDK does not enter its
post-then-edit fallback. Non-empty streams are fully buffered and committed in
one request. Empty string posts are the same no-op. No partial or placeholder
Message reaches Relay.
Explicitly unsupported
The adapter throws Chat SDK NotImplementedError rather than calling an
undocumented route for:
- message editing and deletion;
- editable drafts or partial streaming bubbles;
- open-only direct messages;
- public Contact lookup;
- backward history pagination.
Relay's locked chat-history cursor advances oldest-to-newest. It cannot satisfy
Chat SDK's backward-cursor semantics, so callers must request
direction: "forward" explicitly.
Cards have no Relay interaction surface. Their fallback text is sent; a card without text is rejected.
Webhooks
The adapter internally verifies current signed Standard Webhooks over the exact raw body:
HMAC-SHA256(secret, "${webhook-id}.${webhook-timestamp}.${rawBody}")Every valid current event type is acknowledged. message.received is
dispatched to Chat SDK message handlers, and non-self reaction.added /
reaction.removed events are dispatched to reaction handlers. Receipt,
participant, Chat metadata, typing, and Contact events have no matching Chat
SDK inbound hook and are acknowledged without fabricated behavior.
Direct Chats route through Chat SDK's direct-message path and do not need an
isMention flag. In a group, isMention is true only when a canonical text
part's mention equals the receiving Chat owner_handle; when agentId is
configured, the owner UUID must match it.
This package adds no persistence and no adapter-owned delivery-idempotency
store. Think Actions remain the delivery-idempotency owner outside webhook
turns. RelayClient.sendMessage() requires an explicit idempotency key.
Development
npm ci
npm run check
npm run build
npm run test:unit
npm run test:workerd
npm run test:installed@chat-adapter/[email protected] provides shared adapter utilities and errors. That
published package has no /tests export; Vercel's published contract runner is
@chat-adapter/[email protected], which this package uses alongside
@chat-adapter/shared.
