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

@linqapp/chat-sdk-adapter

v0.5.1

Published

Linq adapter for Chat SDK — build chatbots that talk over iMessage and SMS

Readme

@linqapp/chat-sdk-adapter

Linq adapter for Chat SDK (chat). Build agentic chatbots that talk over iMessage and SMS through Linq, using the same handler code you'd write for Slack, Telegram, or WhatsApp.

Install

npm install @linqapp/chat-sdk-adapter chat

Quick start

import { createLinqAdapter } from "@linqapp/chat-sdk-adapter";
import { Chat } from "chat";

const chat = new Chat({
  userName: "mybot",
  adapters: {
    linq: createLinqAdapter({
      apiKey: process.env.LINQ_API_KEY!,
      signingSecret: process.env.LINQ_WEBHOOK_SECRET!,
    }),
  },
});

chat.onDirectMessage(async (thread, message) => {
  await thread.subscribe();
  await thread.post(`you said: ${message.text}`);
});

chat.onReaction(["thumbs_up"], async (event) => {
  await event.thread.post("appreciate the tapback 🫡");
});

Then route Linq webhooks to the adapter from any framework with fetch-style handlers:

// e.g. a Nitro/Next.js/Hono POST route
export default async (request: Request) => {
  return chat.webhooks.linq(request);
};

Point a Linq webhook subscription at that route and subscribe to at least:

  • message.received
  • reaction.added
  • reaction.removed

Other event types are acknowledged with a 200 and ignored.

Configuration

| Option | Required | Description | | ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | apiKey | direct | Linq API key used for all outbound API calls. | | signingSecret | direct | Webhook signing secret. Deliveries are verified with Standard Webhooks, including replay-window checks. | | credentials | managed | Lazy function returning { apiKey, signingSecret }; use this for rotated or externally managed credentials. | | webhookVerifier | no | Verifies a trusted forwarded webhook using the unmodified raw body. Takes precedence over signingSecret. | | baseURL | no | Override the Linq API base URL (e.g. sandbox). |

Use either the direct apiKey + signingSecret pair or credentials. A trusted webhook forwarder can use webhookVerifier instead of Linq's direct signature:

createLinqAdapter({
  credentials: async () => ({
    apiKey: await secrets.get("linq-api-key"),
    signingSecret: await secrets.get("linq-webhook-secret"),
  }),
  webhookVerifier: async (request, rawBody) => verifyForwardedWebhook(request, rawBody),
});

Supported features

| Feature | Status | | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | Inbound text messages | ✅ | | Outbound text messages | ✅ | | Inline text decorations | ✅ markdown styling sent as text_decorations (iMessage only) | | Idempotent sends | ✅ idempotencyKey send option | | Group chats | ✅ reply to existing groups received via webhook | | Inbound media (images, audio, files) | ✅ parsed as attachments with downloadable data | | Outbound media / file sending | ✅ attachments and files on a message become media parts | | Inbound reactions (tapbacks + custom emoji) | ✅ dispatch to onReaction() | | Outbound reactions (add/remove) | ✅ | | Edit message | ✅ text, first part only | | Fetch message / history / thread | ✅ | | Typing indicators | ✅ DMs only (Linq rejects typing in groups) | | Webhook signature verification + replay protection | ✅ | | Delivery status (sent / delivered / read / failed) | ✅ via adapter.onDeliveryStatus() | | Streaming | ⚠️ buffered — recipients see one final message | | Sticker reactions | ❌ skipped (no Chat SDK equivalent) | | Delete message | ❌ Linq cannot unsend on the recipient's device | | openDM() / creating chats | ✅ returns a pending thread; the chat is created on its first message | | Cards | ⚠️ rendered natively as plain text + image media parts — buttons/selects show their labels but cannot trigger onAction() | | Modals, slash commands | ❌ no Linq equivalent |

Delivery status

Chat SDK has no delivery-status dispatch, so outbound message outcomes surface on the adapter. Without this a caller cannot tell a delivered message from one the carrier rejected — every send looks like a success:

const adapter = createLinqAdapter({ apiKey, signingSecret });

adapter.onDeliveryStatus((event) => {
  if (event.status === "failed") {
    console.error(`send failed on ${event.threadId}`, event.error);
  }
});

From a Chat SDK app, reach it with chat.getAdapter("linq"), which keeps the concrete adapter type rather than the Adapter interface. Subscribing returns an unsubscribe function, and a listener that throws is logged rather than allowed to fail the webhook — a non-2xx response would make Linq retry the delivery.

Requires subscribing to message.sent, message.delivered, message.read, and message.failed on the webhook subscription.

Webhook event coverage

Linq sends far more event types than a Chat SDK adapter can act on. src/webhook-events.ts records a disposition for every one — either handled, or ignored with the reason.

The record is keyed by the SDK's own WebhookEventType, so it must name every event Linq can send. When an @linqapp/sdk upgrade adds one, the record is missing a key and typecheck fails, surfacing the drift on the upgrade's own pull request instead of as silence in production. @linqapp/sdk went from 25 event types in 0.22.1 to 44 in 0.40.0 — nineteen arrived in one upgrade and nothing noticed.

Events are ignored for one of two reasons:

  • Not yet mapped — Linq supports it and Chat SDK can express it; the adapter has not wired it up.
  • No Chat SDK primitive — payments, polls, location sharing, and calls have no equivalent in a messaging abstraction. Reach them through the concrete adapter with bot.getAdapter("linq"), which keeps its real type.

Unhandled events are still acknowledged with a 200, so Linq does not retry them.

Thread IDs

Thread IDs are stable and always take the form linq:{chatId}, regardless of whether the thread was first seen via webhook or API. Group vs DM identity is tracked internally from webhook payloads and chats.retrieve() calls; legacy linq:{chatId}:group / linq:{chatId}:dm IDs from older versions still decode.

openDM() returns a pending thread ID, linq:pending:{handle}, for someone you have no chat with yet. Linq has no empty-chat primitive — a chat is created by its first message — so the target handle rides in the ID and the chat is created when you post:

const threadId = await adapter.openDM("+12025550147");
const sent = await adapter.postMessage(threadId, "hey, following up on your order");
sent.threadId; // "linq:9c1f0a2e-..." — the real chat, from here on

The ID is deterministic, so you can address a handle without a round trip. Posting reuses an existing chat with the same recipients rather than forking a second conversation. Any other operation on a pending thread — fetching messages, typing, reactions — throws, because there is nothing to act on yet.

Text decorations

Markdown formatting is sent as real iMessage styling rather than flattened to plain text:

await thread.post({ markdown: "your order **shipped** today" });
// → value: "your order shipped today"
//   text_decorations: [{ range: [11, 18], style: "bold" }]

**bold**, _italic_, and ~~strikethrough~~ all map across, and nested styles produce overlapping ranges. Decorations render per recipient: in a mixed group, iMessage participants see the styling and SMS/RCS participants get the same message as plain text.

Underline and the animated effects have no markdown syntax, so they go through the send options — which also carry the idempotency key:

await adapter.postMessage(
  threadId,
  { markdown: "your order shipped" },
  {
    textDecorations: [{ range: [0, 4], animation: "shake" }],
    idempotencyKey: job.id,
  },
);

Caller decorations are appended to the ones derived from the markdown. Styles may overlap each other freely, but an animation may not overlap any other decoration — the adapter throws rather than letting the API reject the send.

Make the idempotency key stable across retries of the same logical send; a value generated per call dedupes nothing.

Attachments

Attach media by putting attachments or files on a message:

await thread.post({
  markdown: "here's the report 📎",
  attachments: [
    { type: "file", url: "https://example.com/report.pdf", mimeType: "application/pdf" },
  ],
});

// or send raw bytes
await thread.post({
  markdown: "fresh render",
  files: [{ filename: "render.png", mimeType: "image/png", data: pngBuffer }],
});

How each attachment is delivered:

  • Public HTTPS URL, ≤ 10MB — sent by reference; Linq downloads it on send. No upload round-trip, so forwarding inbound Linq media (already on cdn.linqapp.com) is free.
  • Raw bytes, non-HTTPS URLs, or files > 10MB — uploaded via POST /v3/attachments (up to 100MB) and sent by attachment_id.

A message can be media-only (no text). Inbound attachments expose fetchData() to download, and survive queue serialization via rehydrateAttachment (Linq CDN URLs don't expire). Audio is sent as a downloadable file attachment — the dedicated iMessage voice-memo bubble endpoint isn't wired up yet.

Cards

iMessage/SMS has no rich-card UI, so Chat SDK cards are flattened to their closest native equivalent instead of being dropped:

  • Title, subtitle, text, fields, links, dividers, and tables render as clean plain text (markdown is stripped — iMessage would show literal **).
  • <Image> elements and the card's imageUrl are sent as real image media parts (public HTTPS URLs only; other URLs stay visible in the text).
  • Buttons and selects render their labels (e.g. Options: Approve, Reject) so the recipient sees what the card offers — but there are no tappable buttons on iMessage, so onAction() handlers never fire from this adapter. The adapter logs a warning on every such send so the degradation is visible instead of silent. If you need a working action, include a LinkButton/CardLink URL or handle plain text replies.
  • An explicit fallbackText on { card, fallbackText } replaces the generated text; card images are still attached.
await thread.post(
  <Card title="Order #1234">
    <Image url="https://example.com/receipt.png" alt="Receipt" />
    <CardText>Your order has been received!</CardText>
    <CardLink url="https://example.com/orders/1234" label="Track order" />
  </Card>,
);
// → one iMessage: text bubble + attached receipt image

Reactions

Standard iMessage tapbacks map to normalized Chat SDK emoji in both directions:

| Linq tapback | Chat SDK emoji | | ------------ | -------------- | | like | thumbs_up | | dislike | thumbs_down | | love | heart | | laugh | laugh | | emphasize | exclamation | | question | question |

Custom emoji reactions pass through the default emoji resolver (e.g. 👍thumbs_up), falling back to the raw emoji for anything unmapped.

Development

pnpm install
pnpm test        # vitest
pnpm typecheck
pnpm build

A full example app (Nitro server wiring Linq, Telegram, and WhatsApp adapters into one bot) lives in apps/api in this repo.

Live smoke test

smoke-live.mjs drives this adapter against the real Linq API so you can validate a sandbox in one command. Run pnpm build first (it imports ./dist).

Get a sandbox number with the Linq CLI: linq signup --phone <your cell>, then grab the token from ~/.linq/config.json.

# signing: create a throwaway webhook subscription, sign a delivery with the
# secret Linq actually issued, and run it through the adapter. Sends nothing.
LINQ_API_KEY=<token> node smoke-live.mjs verify

# proactive: openDM a handle with no existing chat, then post to it
LINQ_API_KEY=<token> LINQ_TEST_TO=<your cell> node smoke-live.mjs opendm

# outbound: bootstrap a chat and send text + two images (one by URL, one pre-uploaded)
LINQ_API_KEY=<token> LINQ_FROM=<sandbox number> LINQ_TEST_TO=<your cell> \
  node smoke-live.mjs send

# cards: send Chat SDK cards end-to-end — a full text card, a card with an image,
# and the image+buttons-only card that used to silently vanish
LINQ_API_KEY=<token> LINQ_FROM=<sandbox number> LINQ_TEST_TO=<your cell> \
  node smoke-live.mjs cards

# inbound: receive real webhooks (text + reactions), optionally echo-reply
LINQ_API_KEY=<token> LINQ_SIGNING_SECRET=<webhook secret> LINQ_ECHO=1 \
  node smoke-live.mjs serve
# then tunnel it (cloudflared/ngrok) and register the URL as a Linq webhook subscription

| Env | Mode | Purpose | | ---------------------------- | ----- | --------------------------------------------------------------------------------- | | LINQ_API_KEY | both | Linq API token | | LINQ_FROM / LINQ_TEST_TO | send | sender (sandbox) number / your phone — or set LINQ_TEST_CHAT_ID to reuse a chat | | LINQ_SIGNING_SECRET | serve | webhook signing secret (from the subscription) | | LINQ_BASE_URL | both | override API base URL (optional) | | LINQ_ECHO=1 | serve | reply to inbound messages so you get a round-trip on the device |

License

Apache-2.0