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

@skein-js/channels

v0.18.0

Published

Channels and integrations for real-world LangGraph workflows — authenticated sources and durable destinations for WhatsApp, Slack, email, webhooks, and more.

Readme

@skein-js/channels

Connect LangGraph workflows to external systems through durable sources and destinations. The workflow defines what happens—state, decisions, tools, branching, and interrupts. A channel defines how an authenticated provider event enters that workflow and how its outcome leaves. Together:

channel source → LangGraph workflow → channel destination

skein-js keeps conversations connected, prevents duplicate work, resumes interrupted workflows, and delivers outcomes reliably; each integration only translates its provider's events and deliveries.

LangGraph deliberately doesn't own provider integrations. This package supplies that missing lifecycle around the graph. For example:

  • WhatsApp question → order lookup and escalation workflow → WhatsApp answer;
  • customer email → refund validation and approvals → WhatsApp approvers → customer email;
  • GitHub deployment webhook → failure triage workflow → Slack on-call alert.

Part of skein-js. Entirely optional: a deployment that configures no channel cannot tell this package exists.

Why workflows need channels

LangGraph can express and persist the business process, but a provider connection still needs signature verification, dedup for retries, a mapping from whatsapp:+254… to a thread, a branch on whether that thread is waiting on a human, payload mapping in both directions, and a reply path that does not double-send.

Only two of those are about the provider. The rest are identical for every integration anyone will ever write — and the interesting failures (a double reply, a lost reply, an interrupt that never resumes) land in front of end users rather than in a test.

How a channel wraps a workflow

A channel implements two required methods. Everything else is skein's, once, for every channel.

import type { Channel } from "@skein-js/channels";

export const channel: Channel = {
  name: "twilio",

  // Runs before any parsing, because that is the only point at which a signature can still be
  // checked. Returns a principal, not a boolean — see below.
  verify(request) {
    const expected = sign(authToken, request.url.href, request.form());
    if (!equalsConstantTime(expected, request.headers["x-twilio-signature"] ?? "")) return false;
    return { identity: `channel:twilio:${request.form()["From"]}` };
  },

  // The integration: provider payload in, event out.
  parseEvent(request) {
    const message = request.form();
    if (!message["Body"]) return { kind: "ignore" };
    return {
      kind: "event",
      event: {
        threadKey: message["From"]!,
        idempotencyKey: message["MessageSid"]!,
        replyTo: message["From"]!,
        input: { messages: [{ role: "human", content: message["Body"] }] },
      },
    };
  },

  // Durable: retried, recorded, replayable, survives the process dying mid-run.
  async deliver(outcome, target) {
    if (outcome.reply) await sendWhatsApp(target as string, String(outcome.reply));
  },
};

Then bind it to a graph in langgraph.json — the binding is deployment knowledge, so a community adapter never has to know what you named your graph:

{
  "graphs": { "support": "./src/support.ts:graph" },
  "skein": {
    "channels": {
      "twilio": {
        "path": "./src/twilio-channel.ts:channel",
        "assistant": "support",
        "public_url": "https://api.example.com",
      },
    },
  },
}

Route a workflow from one provider to another

Use composeRoutedChannel when the inbound provider should not own outbound delivery. The source still verifies and parses events; an application-owned map supplies allowlisted destinations:

import { composeRoutedChannel, type ChannelDestinationDelivery } from "@skein-js/channels";

const destinations = new Map([
  [
    "whatsapp",
    async ({ target, payload, runId }: ChannelDestinationDelivery) => {
      const message = whatsappPayloadSchema.parse({ target, payload });
      await sendWhatsApp(message, { idempotencyKey: runId });
    },
  ],
]);

export const channel = composeRoutedChannel(emailSource, destinations);

The workflow selects a destination after processing:

import { declareChannelDestinationDelivery } from "@skein-js/channels";

declareChannelDestinationDelivery(config.writer, {
  destination: "whatsapp",
  target: { to: "+254700000001" },
  payload: { body: "Order GT-1042 needs approval." },
});

Only this explicit declaration is dispatched. Ordinary replyWith values and inferred interrupt or AI-message replies are intentionally ignored by a routed channel. Targets and payloads must be plain JSON values, and every destination must validate its provider-specific boundary. The map allowlists adapters, not recipients: authorize targets from trusted workflow data instead of treating an LLM-selected or user-supplied value as permission to send. An invalid or unknown declaration, or a callback that throws, fails the existing outbox attempt visibly.

Delivery remains at-least-once. Use a stable provider idempotency key such as runId; a run still has one outbox retry unit, even when one callback performs aggregate fan-out. Existing channels that implement Channel.deliver directly continue to work unchanged.

Decisions worth knowing

verify returns a principal, not a boolean. A provider's signature is an authentication scheme; it just is not a bearer token. Producing an identity lets an inbound event flow through the deployment's ordinary Auth block — @auth.on.threads handlers see it, ownership filters apply, multi-tenancy works. The alternative, a route exempt from authorization, would be an unauthenticated run-creation endpoint.

verify is required. A provider with no signature scheme must still produce a principal some other way — a secret path segment, a shared-secret query parameter, basic auth. Weaker is a decision a deployment can make; absent is not.

Events, not chat messages. There is no from, to, body or typing in these types. Chat is the case where the reply target happens to be the sender; a GitHub webhook and a Stripe dispute are the same pipeline without that property. Those names appearing here is what would stop them fitting.

deliver and onSignal have opposite guarantees, which is why they are separate methods rather than one with a flag. deliver is durable, at-least-once and replayed from the outbox. onSignal is best-effort and lost by design — retrying a "typing" indicator four minutes late is nonsense, and making the answer best-effort would defeat the point.

Thread ids are derived, and the derivation is exported. threadIdForChannelKey(channel, key) hashes the key, so a phone number never lands in a primary key, an index or a backup. It is exported rather than hidden because an id you cannot recompute is not a primitive — with it, ending a conversation is threads.delete(threadIdForChannelKey("twilio", "whatsapp:+254…")) and needs no new API. The raw key is stamped into thread metadata, so POST /threads/search answers the same question from the other direction, which is the shape a GDPR erasure request arrives in.

Configuration is validated at boot, not at the first event. An assistant naming a graph that does not exist fails startup with a precise error — discovering that typo when the first customer texts is the failure this avoids. Configured route keys and explicit Channel.name aliases must also be unambiguous; collisions fail at boot before they can merge thread identities or misroute a reply.

See also