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

birdcash-chat-sdk-alpha

v4.0.1

Published

TypeScript SDK for sending messages through the chat API (text, images, files, miniapp cards, streaming).

Readme

birdcash-chat-sdk

A small TypeScript SDK for sending messages through the chat API. It wraps the /v1/chat/messages/:eventID/reply and /v1/upload/* endpoints so you can send text, images, files, miniapp cards, web-link previews, typing status, and streaming (typewriter) responses.

Install

npm install birdcash-chat-sdk-alpha

Quick start

import { ChatClient } from "birdcash-chat-sdk-alpha";

const chat = new ChatClient({
  token: "<bearer-token>",
  // baseUrl: 'https://your-chat-api',   // optional, defaults to the hosted API
  // logger: console,                    // optional, omit for silence
});

// …or exchange OAuth client credentials for a token automatically:
const chat2 = await ChatClient.fromCredentials({ clientId, clientSecret });

// Send text (string = one element; array = one request, one element per item)
await chat.sendMessage(eventID, "Hello there!"); // webhook envelope id (`evt_…`)
await chat.sendMessage(eventID, ["First line", "Second line"]);

Options

| Option | Type | Default | Description | | --------- | -------------- | --------------- | ------------------------------------------ | | token | string | — (required) | Bearer token sent with every request. | | baseUrl | string | hosted endpoint | Base URL of the chat API. | | logger | Logger | undefined | Pass console to log; omit for no output. | | fetch | typeof fetch | global fetch | Custom fetch (Node < 18, tests, etc.). |

Sending messages

// Images: upload first, then send by upload_id
const { upload_id } = await chat.uploadImage(bytes, "photo.png", "image/png");
await chat.sendImageMessage(eventID, [upload_id]);

// Files
const file = await chat.uploadFile(bytes, "report.pdf", "application/pdf");
await chat.sendFileMessage(eventID, [file.upload_id]);

// Sound (uploaded via uploadFile; duration in seconds)
const audio = await chat.uploadFile(bytes, "clip.m4a", "audio/m4a");
await chat.sendSoundMessage(eventID, audio.upload_id, 12);

// Sticker (by pack + sticker identifiers)
await chat.sendStickerMessage(eventID, "weather", "Cloud with lightning");

// Typing indicator (ephemeral `event.typing`; same webhook eventID as reply)
await chat.sendTypingStatus(eventID, true);
await chat.sendTypingStatus(eventID, false);

// Miniapp card
await chat.sendMiniAppMessage(eventID, {
  app_id: "app123",
  title: "Open the app",
  path: "/home",
  image_url: "https://…/cover.png",
});

// Link preview card (`link.preview`)
await chat.sendWebLinkMessage(eventID, "https://example.com");
// or with optional metadata:
await chat.sendWebLinkMessage(eventID, {
  url: "https://example.com",
  title: "Example",
});

// Choices prompt (`ai.choicePrompt`, bot accounts) — returns the new message id.
// Selection is applied via WS `message.interact`, not by editing the card.
const choiceMsgID = await chat.sendChoicesMessage(eventID, {
  prompt: "Which shipping option do you want?",
  choices: [
    { id: "standard", label: "Standard (5–7 days)" },
    { id: "express", label: "Express (2 days)" },
  ],
  // selectionMode: 'single',  // optional, defaults to 'single'
});

// Official-account post (`oa.post`) — server-origin only; client/bot REST sends
// are rejected by the registry.
await chat.sendOfficialAccountMessage(eventID, {
  title: "Release notes",
  body: "What shipped this week…",
  link: "https://example.com/blog",
});

Note cards

Styled text cards rendered by the server. You send text and a style id — never pixels — and get back staged upload_ids that attach like any other image.

// The style registry. Read it rather than hardcoding ids: styles get added, and
// `groups` is the order a picker should render its sections in.
const { styles, groups, canvas } = await chat.noteStyles();

// Preview the user's own text in a style. Nothing is staged, so a picker can
// render every style without burning an upload_id per swatch.
const png = await chat.noteCardPreview({ text: "Back in stock.", style: "navy" });

// Render and stage. One upload_id per card, in order.
const { upload_ids } = await chat.uploadNoteCards([
  { text: "Starting the morning off right.", style: "navy" },
  { text: "New drop this week.", style: "plain-statement" },
]);

// Or render and send as a chat message in one call.
await chat.sendNoteCardMessage(eventID, [
  { text: "Back in stock.", style: "navy", highlight_words: ["stock"] },
]);

Per-card overrides are highlight_words, highlight_color, highlight_style (marker or color), uppercase and sticker. Everything else — font, ground, alignment, sizing — belongs to the style. Text is capped at 600 characters.

canvas is portrait (default, 4:5), square, or story (9:16), and applies to the whole request so a carousel cannot end up with mixed aspect ratios.

Note posts

import { createNotePost, getNoteStyles, noteCardPreviewURL } from "birdcash-chat-sdk";

await createNotePost(token, {
  title: "New drop this week.",
  // Hashtags live on the post caption — card text is pixels and is not parsed.
  content: "Heavyweight hoodie, back in stock. #fw26",
  notes: [
    { text: "Front.", style: "navy" },
    { text: "New drop this week.", style: "plain-statement" },
  ],
});

// For a picker UI, put preview URLs straight in <img src> instead of fetching.
const src = noteCardPreviewURL({ text: draft, style: "butter" });

Every commerce helper takes an optional trailing { baseUrl, fetch } so a call that spans two endpoints — like createNotePost, which uploads then posts — sends both halves to the same host.

Editing & deleting

Send with a known replyMsgID, then edit that message in place (or delete it):

const editable = crypto.randomUUID();
await chat.sendMessage(eventID, "Working on it…", editable);
await chat.editTextMessage(editable, "Done ✅");

// Stickers and images edit in place too
await chat.sendStickerMessage(eventID, "capoo", "capoo_1", editable);
await chat.editStickerMessage(editable, "capoo", "capoo_2");
await chat.editImageMessage(imgMsgID, [upload_id]);

// Delete a message you sent
await chat.deleteMessage(editable);

Streaming (typewriter effect)

Streams have their own routes (docs/api/chat/stream-frames.md): one message, opened once on POST /v1/chat/streams — the server names it and returns the id — and advanced by frames on POST /v1/chat/streams/{id}/frames. A frame names a contiguous revision and any of four channels — answer, reasoning, sources, activity — each with {set} or {append}. Only a terminal status is ever named; the server derives pending/streaming.

// Recommended: reply with a throttled token stream
const streamId = await chat.sendStreamMessage(eventID, "A long streamed reply…");

// Low-level: drive each frame yourself
const { streamId } = await chat.openStream({ eventId });
await chat.postFrame(streamId, deltaFrame(1, appendReasoning("The user wants a capital. ")));
await chat.postFrame(streamId, deltaFrame(2, appendAnswer("Paris"), setReasoning("")));
await chat.postFrame(streamId, deltaFrame(3, appendSources([{ title: "Source", url: "https://…" }])));
await chat.settle(streamId, 4);                                       // no text: the server holds it
await chat.settle(streamId, 4, "complete", { repair: fullText });     // when a delta was lost

// Long quiet stretch
const ack = await chat.heartbeat(streamId);
if (ack.cancelRequested) { /* the user pressed stop; nothing more will be accepted */ }

// After a timeout
const { snapshot } = await chat.getStream(streamId);

Streaming a reply

streamWriter is the driver: it owns revisions, buffering and its cadence, the heartbeat that holds the lease through a long think, the abort that stops your model when the user presses stop, and a settle that closes the stream even when the server refuses what it carried.

const stream = await chat.streamWriter(replyToFrom(msgID), { placeholder: "Thinking…" });
try {
  for await (const chunk of model({ signal: stream.signal })) {
    await stream.answer.append(chunk);
  }
  await stream.settle();
} catch (e) {
  await stream.fail();
}

stream.answer and stream.reasoning take append / set / flush; stream.sources and stream.activity take push / set. Pass stream.signal to whatever produces tokens — without it a cancelled generation keeps running.

Frames are safe to retry at the same revision: the server recognizes a replay and answers success, refuses a reused revision (REVISION_TAKEN), refuses a skipped one (REVISION_GAP) unless the frame repairs with answer.set, and says how long to wait on 429. postFrame/settle throw StreamFrameRejectedisConflict, isRevisionTaken, isCancelled, isRateLimited (with retryAfterMs) and isPermanent name the cases. sendStreamMessage handles all of it.

Frames on one stream must be at least MIN_FRAME_INTERVAL_MS (50 ms) apart, and postFrame waits that out for you before sending — so a progress line followed immediately by an answer you formatted in memory is paced rather than refused. A 429 that gets through anyway is retried with the same frame, because a refused frame does not land and its content would otherwise be lost.

OAuth

If you don't already have a bearer token, exchange client credentials for one:

import { getAccessToken } from "birdcash-chat-sdk-alpha";

const token = await getAccessToken({
  clientId,
  clientSecret,
  scope: "chat:write uploads:write", // default
});
// token.access_token, token.expires_in, …

Verifying webhooks

Validate incoming webhook requests before trusting them. The signature scheme matches the server: HMAC-SHA256(secret, "${timestamp}.${rawBody}"), sent in the X-Webhook-Signature (optionally sha256=-prefixed) and X-Webhook-Timestamp headers. Pass the raw body text, read before JSON-parsing.

import {
  verifyWebhookSignature,
  isLedgerWebhookEvent,
  type WebhookEventBody,
} from "birdcash-chat-sdk-alpha";

const raw = await request.text();
const { valid, error } = await verifyWebhookSignature(
  request,
  env.WEBHOOK_SECRET,
  raw,
);
if (!valid) return new Response(error ?? "bad signature", { status: 401 });

const event = JSON.parse(raw) as WebhookEventBody;
if (event.type === "message.new") {
  const { msg_id, text_elem } = event.payload;
  // plaintext: text_elem?.text
  // E2EE: text_elem?.encrypted_payload (snake_case nested keys)
}
if (isLedgerWebhookEvent(event)) {
  // Money movement: defining-leg `amount` (minor units) + `transactions`.
  const { event_type, amount, order_id, transactions } = event.payload;
}

message.new payload fields are snake_case (msg_id, elem_type, text_elem, encrypted_payload, …) — same convention as REST reply bodies. Types: MessageNewPayload / MessageNewBody (also exported from ./webhook).

Shared places (location_elem)

A place arrives on location_elem, in one of two shapes: the four fields in the clear, or every field null and the whole place sealed in encrypted_payload — the same envelope text_elem uses, because a place is small enough to encrypt whole (no blob to fetch). Which one you get is the sending client's per-conversation choice; public and community groups are always plaintext.

readInboundLocation takes either, so a handler need not branch:

import { readInboundLocation } from "birdcash-chat-sdk-alpha";

const place = await readInboundLocation(event.payload, {
  chatPrivateKey: env.CHAT_KEY_PRIVATE, // only needed for a sealed place
});
if (place) {
  console.log(place.name ?? `${place.latitude}, ${place.longitude}`);
}

It returns null rather than throwing when there is no place, no key, or no envelope this key can open. Use decryptInboundLocationMessage when you want the error — it takes the same options as decryptInboundTextMessage and returns { latitude, longitude, name?, address? }.

Two things worth knowing. location_elem is not location — the latter is where the sender's session is connecting from, resolved server-side, and has nothing to do with a shared pin. And the coordinate rules the server applies to a plaintext place cannot run on a sealed one, so decryptInboundLocationMessage does the minimum here instead: ciphertext that opens to something without a finite coordinate is rejected rather than handed back as a pin at (0, 0).

Bots can receive places but not send them: the reply rail has never built location messages.

ledger.* is the money-movement rail (one POST per committed ledger-event status). A capture fires both order.captured and ledger.order_capture.completed; the latter carries merchant net proceeds and the platform-fee leg. Types: LedgerEventPayload / LedgerEventBody / isLedgerWebhookEvent.

It rejects missing headers, bad signatures (constant-time compare), and stale timestamps (replay protection, default 300s — override with { toleranceSec }). Pass { logger: console } for diagnostics.

Lower-level exports

The element builders and helpers are also exported if you want to assemble requests yourself:

import {
  ElemType,
  textElem,
  imageElem,
  splitIntoChunks,
  toBase64,
} from "birdcash-chat-sdk-alpha";