Skip to content

Workflows and channels — connect LangGraph to external systems ​

Connect an external event to your graph and deliver its result back to the right person or system. For example, a customer emails a refund request, your graph asks a reviewer on WhatsApp, and the reviewer's answer lets it finish the request and notify the customer.

A channel verifies and translates provider events. Your workflow is the LangGraph graph that makes decisions, calls tools, and pauses for approval. Configure allowed destinations before the graph can choose where to send a result.

Choose a connection ​

Your workflowStart here
Answer an order question in the same WhatsApp chatCoupled replies
Receive email, request WhatsApp approval, then notify the customerCross-provider workflow
Accept a GitHub or internal-system eventImplement the channel source below and route to your graph
Notify an existing HTTP service when any run finishesRun-completion webhooks may be enough

Skein handles inbound deduplication, thread/run coordination, and durable outbound delivery. You supply provider signature verification, event parsing, business decisions, destination policy, and provider calls. There are no built-in Slack, WhatsApp, or email provider packages to configure.

Quick start ​

bash
pnpm add @skein-js/channels

Write the provider adapter and bind its source route to a graph:

src/twilio-channel.ts

ts
import { equalsConstantTime } from "@skein-js/agent-protocol";
import type { Channel } from "@skein-js/channels";

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

  verify(request) {
    const expected = sign(process.env.TWILIO_AUTH_TOKEN!, request.url.href, request.form());
    if (!equalsConstantTime(expected, request.headers["x-twilio-signature"] ?? "")) return false;
    return { identity: `channel:twilio:${request.form()["From"]}` };
  },

  parseEvent(request) {
    const message = request.form();
    if (!message["Body"]) return { kind: "ignore" };
    return {
      kind: "event",
      event: {
        threadKey: message["From"]!,
        idempotencyKey: message["MessageSid"]!,
        replyTo: { to: message["From"] },
        input: { messages: [{ role: "human", content: message["Body"] }] },
        resumeWith: message["Body"],
      },
    };
  },

  async deliver(outcome, target) {
    const { to } = target as { to: string };
    if (outcome.reply !== undefined) await sendWhatsApp(to, String(outcome.reply));
  },
};

langgraph.json

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

That serves POST /channels/twilio. Point your provider's webhook at it.

A complete, runnable version — offline, no Twilio account — is examples/whatsapp-agent.

Let a workflow route between providers ​

The source that starts a workflow does not have to receive the result. The decoupled-delivery example sends an email into one LangGraph workflow, routes approval requests to WhatsApp, collects parallel authenticated approvals with LangGraph interrupt(), then sends the result by email. Its runnable refund demo uses Skein's source, destination, auth, deduplication, thread/run and delivery paths with offline provider fakes.

Skein exposes this as a small composition layer rather than separate provider hierarchies:

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

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

export const channel = composeRoutedChannel(emailSource, destinations);

How LangGraph fills the middle and selects the destination ​

LangGraph passes a custom-stream writer to every node in LangGraphRunnableConfig. The Skein helper uses that existing writer to declare the result of the workflow; the node does not call WhatsApp or email itself:

ts
import {
  Annotation,
  END,
  START,
  StateGraph,
  type LangGraphRunnableConfig,
} from "@langchain/langgraph";
import { declareChannelDestinationDelivery } from "@skein-js/channels";

const RelayState = Annotation.Root({
  source: Annotation<"email" | "whatsapp">,
  sender: Annotation<string>,
  whatsappNumber: Annotation<string | undefined>,
  subject: Annotation<string>,
});

function routeMessage(
  state: typeof RelayState.State,
  config: LangGraphRunnableConfig,
): Partial<typeof RelayState.State> {
  if (state.source === "email" && state.whatsappNumber) {
    declareChannelDestinationDelivery(config.writer, {
      destination: "whatsapp",
      target: { to: state.whatsappNumber },
      payload: { body: `Important email from ${state.sender}: ${state.subject}` },
    });
  } else {
    declareChannelDestinationDelivery(config.writer, {
      destination: "email",
      target: { to: state.sender },
      payload: { subject: "Assistant update", body: state.subject },
    });
  }

  // The destination declaration is an output instruction, not graph state.
  return {};
}

export const graph = new StateGraph(RelayState)
  .addNode("route-message", routeMessage)
  .addEdge(START, "route-message")
  .addEdge("route-message", END)
  .compile();

The workflow connection is deliberately small:

  1. Skein's source converts email or WhatsApp into ordinary LangGraph input.
  2. LangGraph performs extraction, classification, branching, Send, and interrupt() as usual.
  3. The graph writes one explicit destination declaration through config.writer.
  4. When the run settles, Skein resolves that declaration and invokes the allowlisted destination through its durable outbox.

For approvals, keep using LangGraph's interrupt(); no Skein approval abstraction is needed. A node can declare the WhatsApp approval request before it interrupts, and declare the final email result after interrupt() returns on resume. The complete parallel HR/Manager/Finance implementation is examples/decoupled-delivery/src/relay-graph.ts.

The map allowlists destination adapters and remains application-owned, along with provider credentials and routing policy. It does not authorize recipients or operations: derive those from trusted workflow data and enforce tenant/recipient policy before sending, rather than treating an LLM-selected or user-supplied target as permission. Only declareChannelDestinationDelivery triggers routing; ordinary replyWith values and inferred interrupt/AI-message replies are not dispatched by a routed channel. Construct declarations through the helper, never its internal persisted marker.

target and payload must be plain JSON values so the outbox can persist them. They remain unknown to the destination, which must validate its provider boundary—Zod is used above for exactly that. Unknown or malformed declarations and thrown callbacks fail the outbox attempt visibly and are retried. Delivery is at-least-once, so pass a stable key such as runId to providers that support idempotency. One run still owns one outbox retry unit; aggregate fan-out does not create independent delivery rows.

This helper is for channel-ingested runs. A poller using the Agent Protocol run API still uses its own HTTP callback receiver. Existing Channel implementations with deliver continue to work unchanged. Configured route keys and explicit channel-name aliases must be unique; collisions now fail at boot instead of sharing thread identity or misrouting a callback.

For copy-first workflow implementations, compare the practical WhatsApp → order lookup → WhatsApp recipe with the customer refund email → Finance WhatsApp approval → customer email recipe.

The pipeline ​

text
POST /channels/twilio
  → verify()          your signature check → a principal, or 401
  → authorize()       your Auth block, exactly as on every run-creating route
  → parseEvent()      ignore → 204 · respond → that response · event → on
  → dedup             the provider's event id, claimed over the raw bytes
  → thread            a deterministic id, get-or-create
  → start | resume    decided atomically against the thread's real status
  → 2xx               after enqueueing, never after the run finishes
  ├→ onSignal()       progress, best-effort
  └→ deliver()        the answer, durably

Two properties of that shape are worth internalising before you write anything.

The acknowledgement is early, always. Slack retries after three seconds and shows the user an error; Twilio times out comparably. Your parseEvent returns, the run is enqueued, and skein answers 2xx — it never waits for the graph. That is why progress needs onSignal and why the answer needs deliver: by the time either has something to say, the HTTP request is long gone.

verify runs before anything is parsed. A signature covers the bytes as sent, so any middleware that parses first destroys the thing you need to check. skein hands you the body as text for exactly this reason.

Implementing verify ​

ts
verify(request: InboundRequest): Promise<ChannelPrincipal | false> | ChannelPrincipal | false;

Return a principal, or false for a 401.

Why a principal and not a boolean. A provider's signature is an authentication scheme; it just is not a bearer token. Producing an identity lets the message flow through the Auth block you already configured — @auth.on.threads handlers see it, ownership filters apply, multi-tenancy works — instead of needing a route that bypasses authorization to create runs.

ts
return {
  identity: `channel:twilio:${from}`, // derived from provider-verified data only
  permissions: ["threads:write"], // optional; becomes AuthContext.scopes
  metadata: { tenant }, // optional; merged into the user object
};

Build identity only from data the signature covered. A value the caller could have set freely is not an identity, and everything downstream — ownership filters, per-tenant scoping — inherits whatever trust you put in it here.

verify is required. A provider with no signature scheme must still yield a principal some other way: a secret path segment, a shared-secret query parameter, basic auth. Weaker is a decision your deployment gets to make; absent is not, because the route creates runs.

InboundRequest ​

MemberNotes
methodThe HTTP method
urlA URL. The public one — see below
headersLower-cased keys, whatever the transport
text()The body exactly as sent
form()Decoded application/x-www-form-urlencoded
json()Parsed JSON. Throws on a malformed body, so guard it if the provider varies

The body views are lazy and cached, so reading form() in both verify and parseEvent parses once.

url is the public URL, and that matters more than it looks. Twilio signs the full request URL, so verification depends on knowing what the provider saw — and behind a proxy the server cannot know it. The only inputs are Host and X-Forwarded-Proto, both attacker-controlled. So skein takes the origin from public_url in your config and keeps the router's own path. Set it in production, or signature verification will fail in a way that looks like every request being forged.

Comparing signatures ​

ts
import { equalsConstantTime } from "@skein-js/agent-protocol";

Not ===. It returns at the first differing byte, so an attacker who can send many requests and measure your response recovers a valid signature a byte at a time. The length check inside is not decoration either: Node's timingSafeEqual throws on unequal lengths, so the obvious hand-rolled version turns a forged short signature into a 500 instead of a 401.

Implementing parseEvent ​

ts
parseEvent(request: InboundRequest): Promise<ChannelOutcome> | ChannelOutcome;

Three outcomes, and you will need all three.

{ kind: "event", event } — do something ​

ts
return {
  kind: "event",
  event: {
    threadKey: message.From, // which conversation
    input: { messages: [...] }, // what a fresh turn runs with
    resumeWith: message.Body, // what an interrupt gets, if different
    idempotencyKey: message.MessageSid, // the provider's own event id
    replyTo: { to: message.From }, // opaque; handed back to deliver/onSignal
    onExisting: "resume", // default
    assistantId: "triage", // only if allowed_assistants lists it
    metadata: { locale: message.Locale }, // stamped on the thread
  },
};
FieldRequiredNotes
threadKey✅Stable external identity. Hashed into a thread id
input✅Graph input for a fresh turn
threadIdChoose the thread id yourself; skips the derivation entirely
resumeWithWhat interrupt() receives. Defaults to input — usually wrong, see below
idempotencyKeyNo key, no dedup. See Retries
replyToOpaque to skein. Omit it and no reply is delivered
onExistingresume (default) · enqueue · interrupt · reject
assistantIdRejected unless the deployment listed it in allowed_assistants
metadataMerged onto the thread's metadata

{ kind: "ignore" } — acknowledge, do nothing ​

Answers 204 and starts no run. Use it liberally. Slack redelivers your own bot's messages, and a channel that cannot cheaply say "not interesting" ends up answering itself in a loop. Delivery receipts, read receipts, reactions, media-only messages and bot echoes all belong here.

ts
if (payload.bot_id) return { kind: "ignore" };

{ kind: "respond", status, body } — answer this request directly ​

No run, your response. Required by real providers, not a convenience:

ts
// Slack's very first request is a challenge that must be echoed back.
if (payload.type === "url_verification") {
  return { kind: "respond", status: 200, body: { challenge: payload.challenge } };
}
// A slash command wants an immediate ephemeral acknowledgement.
return { kind: "respond", status: 200, body: { response_type: "ephemeral", text: "On it…" } };

Retries and idempotency ​

Providers retry. Twilio retries on any non-2xx and on a timeout; Slack retries three times. Without dedup the customer gets two answers.

Set idempotencyKey to the provider's own event id — MessageSid, Slack's event_id, GitHub's X-GitHub-Delivery. A retry carries the same one, and skein replays the first response instead of starting a second run.

Three things worth knowing about how it behaves:

  • The fingerprint is the raw inbound bytes, not anything derived. A retry that correctly re-reads the thread and resumes instead of starting sends the same key with a different run body — and fingerprinting that derived body would refuse the retry precisely because it did the right thing.
  • Same key, different bytes → 422. Two genuinely different events sharing an id is a provider bug or a forgery; replaying would tell the sender its message was handled while silently dropping it.
  • No key means no protection, deliberately. Without a provider-assigned id nothing identifies two deliveries as the same event, and deriving one from the body would collide when a customer legitimately sends "yes" twice.

Conversations that wait for a human ​

This is the case channels exist for, and the one every hand-written integration gets wrong.

interrupt() parks a thread until someone answers. The run that parked it settles as interrupted, which is a terminal status — so the thread holds no inflight run, no multitask_strategy guards it, and a plain start succeeds and discards the pending question. Silently.

A channel does the right thing by default: a message arriving on a paused thread resumes it. The decision is a precondition on the create, settled inside the driver, so two replicas cannot both resume.

Set resumeWith — you almost certainly need it ​

input is what a fresh turn takes. For a message-shaped graph that is an envelope: { messages: [{ role: "human", content: "yes" }] }. But interrupt() returns whatever the node asked for — usually a scalar the graph author chose:

ts
const answer = interrupt("Approve this refund?");
if (/^y/i.test(String(answer))) { … }

Hand that node the envelope and String(...) gives "[object Object]", which does not match — so the graph reads a "yes" as a "no", and nothing errors. skein will not guess between the two shapes, so say which is which:

ts
input: { messages: [{ role: "human", content: body }] },
resumeWith: body,

Choosing onExisting ​

ValueWhat happensWhen
resumeAnswers a pending interrupt; otherwise queues a new turnDefault. Chat, email, anything conversational
enqueueAlways a new turn, behind whatever is runningA feed of events that each deserve their own run
interruptCancels the run in flight and starts this one"Actually, stop — do this instead"
reject409 unless the thread is genuinely freeAn event that is only valid on an idle conversation

Under resume, a message arriving while the agent is still thinking queues rather than being refused. The server's default multitask_strategy is reject, which for Twilio would render as a failed message for having typed twice; a conversation wants ordering.

When the answer is not an answer — "actually, never mind, what's my balance?" — skein still resumes and the graph decides whether to re-ask, pivot or abandon, because the graph holds the question. If you would rather have a blunter rule, use enqueue.

Delivering the reply ​

ts
deliver?(outcome: RunOutcomeForChannel, target: ReplyTarget): Promise<void>;

Optional — a GitHub channel that comments from inside the graph owes no callback. Omit it and no reply is delivered.

It runs inside skein's delivery outbox, which is where all its guarantees come from. The delivery row is written in the run's finalize transaction, so a crash between "the run succeeded" and "the customer was told" cannot lose the answer. A throw is a failed attempt: retried with exponential backoff, recorded, and replayable from the delivery routes. So let it throw — swallowing an error here is how an answer gets silently dropped.

ts
async deliver(outcome, target) {
  if (outcome.reply === undefined) return; // nothing to say
  const { to } = target as { to: string };
  await sendWhatsApp(to, String(outcome.reply)); // throws → retried
}

What outcome.reply is ​

Resolved by skein, in order:

  1. What the graph declared, via replyWith on the custom stream.
  2. The last AI message in values.messages — LangGraph's MessagesAnnotation convention, so an ordinary chat agent works with no graph changes at all.
  3. The interrupt's question, when the run is interrupted.
  4. Otherwise undefined.

Resolved centrally rather than per channel on purpose: a channel that guessed at state.answer versus state.draft would stop being reusable across graphs, which is the premise the whole plugin surface rests on. For a graph whose state is not message-shaped, declare it:

ts
import { replyWith } from "@skein-js/agent-protocol";

// inside a node, with LangGraph's StreamWriter in scope
writer(replyWith("Your order ships Tuesday."));

A failed run says nothing. error, timeout and cancelled resolve to no reply, because whether an end user hears "something went wrong" is a product decision and leaking internals to a phone number is the wrong default. Send your own message from deliver if you want one.

Progress signals ​

ts
signals: { kinds: ["progress"], keepaliveMs: 10_000 },
async onSignal(signal, target) { … }

Because the acknowledgement is early, progress cannot ride the response. onSignal is how a typing indicator gets sent while the graph works.

Its guarantees are deliberately the opposite of deliver's: best-effort, at most once, never retried, never blocking the run, dropped on any error. Retrying a "typing" indicator four minutes late is nonsense, and a slow provider must never be why the acknowledgement times out. That is why they are two methods rather than one with a flag.

  • progress — fires immediately, then once per frame the run produces.
  • keepalive — fires on keepaliveMs, for providers whose indicator expires (most do, in seconds).

Declaring a subscription is what lets skein pick the cheapest stream that satisfies it. A channel that asks for nothing costs exactly what an API run costs; one that asks for progress gets node-level updates and never pays for token streaming it would only throw away.

Addressing a conversation from outside ​

Thread ids are derived, and the derivation is exported — which is what makes hashing acceptable rather than merely private:

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

const threadId = threadIdForChannelKey("twilio", "whatsapp:+254712345678");
await client.threads.get(threadId);

The phone number never reaches a primary key, an index or a backup. The raw key is stamped into thread metadata, so a search answers the same question from the other side — which is the shape a GDPR erasure request actually arrives in:

ts
await client.threads.search({ metadata: { skein_thread_key: "whatsapp:+254712345678" } });

Ending a conversation and starting a new one ​

Two different things people mean by this, and they need different answers.

"Forget everything and start over." Delete the thread. The next message from that number derives the same id, finds nothing there, and begins fresh:

ts
await client.threads.delete(threadIdForChannelKey("twilio", "whatsapp:+254712345678"));

"Start a new conversation, but keep the old one." Then the thread key is not just the phone number — it is the phone number and which conversation. threadKey is whatever your channel says, so put the session in it:

ts
// A ticket, a billing period, a counter you keep — whatever "a conversation" means to you.
threadKey: `${message.From}:${await currentSessionFor(message.From)}`;

Old conversations stay readable, each under its own thread, and a search on skein_thread_key still finds them because the raw key is what gets stamped.

A channel that already has its own conversation ids returns threadId on the event instead, and skein does no transformation at all.

Configuration ​

KeyMeaning
pathpath:export, or a package name
assistantRequired. Which graph these events run
allowed_assistantsGraphs the channel may route to itself. Omitted means it cannot route
public_urlYour externally reachable origin, when signatures cover the URL

assistant is required and not defaultable. The binding is deployment knowledge: a community Twilio adapter has no business knowing you named your graph support. It takes a graph name or an assistant UUID, resolving exactly as it does for crons.

allowed_assistants is opt-in and bounded for a sharper reason. A channel is an npm package you installed; without a bound, an assistantId derived from untrusted input could reach any graph you serve. Omit the key and the channel cannot route at all.

Everything is validated at boot: a missing assistant, one naming a graph that does not exist, an allowed_assistants entry outside your graphs, a path whose export is not a channel. Discovering any of those when the first customer texts is the failure this avoids.

Testing a channel ​

The quickest end-to-end check is the console's Channels tab. It shows the effective routes and assistant allowlists that passed boot validation. Copy an inbound path, send a fixture or provider event, then open the resulting thread and run: the run's Deliveries panel shows attempts, retry timing, errors, and a confirmed replay action. Sensitive webhook paths and opaque reply targets are redacted in the UI.

The same inventory is available as authenticated GET /channels; it is mounted only when at least one channel is configured and exposes no module paths, public URLs, credentials or raw config.

A channel is a plain object, so verify and parseEvent are unit-testable with no server:

ts
const request = buildInboundRequest({
  method: "POST",
  url: "https://api.example.com/channels/twilio",
  headers: { "x-twilio-signature": signature },
  text: "From=whatsapp%3A%2B254&Body=hi&MessageSid=SM-1",
});

expect(channel.verify(request)).toEqual({ identity: "channel:twilio:whatsapp:+254" });

For the whole path, boot a runtime from a langgraph.json and dispatch into the handler — no HTTP server, no ports:

ts
const resolved = await resolveProtocolRuntime({ config: "./langgraph.json" });
const response = await resolved.runtime.handlers.handleInboundEvent(request);
expect(response.status).toBe(202);

Worth covering, because each has shipped broken in real integrations: a forged signature is refused; a retried delivery produces exactly one run; an interrupt is resumed rather than trampled; a delivery receipt starts no run. examples/whatsapp-agent does all four offline.

Things that will bite you ​

  • Mount order, if you also hand-roll routes. skeinRouter installs a JSON body parser for everything routed into it, so a raw-body route registered after it sees an empty body and fails to verify — a 401 that looks like a forged request. Channel routes are handled for you; your own are not.
  • public_url behind a proxy. Without it, URL-signing providers fail every request.
  • Forgetting resumeWith. The graph reads a "yes" as a "no" and nothing errors.
  • Swallowing errors in deliver. A throw is what buys you the retry.
  • Returning false from verify for a parse failure. That reports a forged request when the truth is a malformed one; prefer { kind: "respond", status: 400 } from parseEvent.
  • Sending the reply from parseEvent. It will be sent again on every retry. The outbox exists so that it is not your problem.

What is not here yet ​

  • First-party channels. There are none: you write the file. Shipping one would make skein responsible for the correctness of someone else's signature scheme, so a flaw becomes a CVE here rather than in your code — a real cost that has to be earned.
  • A channel conformance suite, so a community channel can prove it rejects forged signatures, stale timestamps and duplicate ids without hand review.
  • Chunked streaming of the answer — send it in pieces, crash halfway, and the outbox replays a message the user already partly received. Needs provider-side editing (Slack can, WhatsApp cannot).
  • Attachments. Carry the provider's media URL in metadata and fetch it from the graph.
  • Polled sources (IMAP, queue consumers). The pipeline is entered by an inbound HTTP request, and there is nothing to enter it with when you poll — that is closer to crons.

See also ​

  • webhooks.md — the durable delivery a channel's reply rides on
  • human-in-the-loop.md — interrupt(), and why a reply hours later must resume rather than restart
  • runs.md — if_thread_status, the precondition underneath onExisting
  • agent-protocol.md — the Auth block a verified principal flows through