npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@relaymessenger/chat-sdk-adapter

v0.3.9

Published

Vendor-official Relay adapter for Vercel Chat SDK.

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-adapter

Minimal 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_url and ChatHandle.about fields, 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.