@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 chatQuick 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.receivedreaction.addedreaction.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 onThe 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 byattachment_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'simageUrlare 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, soonAction()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 aLinkButton/CardLinkURL or handle plain text replies. - An explicit
fallbackTexton{ 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 imageReactions
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 buildA 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 |
