Build a cross-provider workflow with Skein channels
Use a routed workflow when a source starts work in one system and LangGraph must choose a different destination: email → LangGraph → WhatsApp, WhatsApp → LangGraph → email, or one source routed to several application-owned provider adapters. The technical mechanism is decoupled channel delivery: Skein supplies the source and destination, while LangGraph fills the middle with the workflow.
Here, a workflow means the complete business process, not merely forwarding a message: validate the request, collect approvals, pause and resume, decide the result, and notify the customer. The workflows and channels guide explains why these responsibilities are split across Skein and LangGraph.
The source still uses Skein's normal channel pipeline. The only extra layer is an allowlisted destination map plus one explicit instruction written by the graph.
The example below handles a concrete request: a customer emails “Please refund KES 27,500 for order GT-1042; I was charged twice.” LangGraph asks Finance for approval over WhatsApp, the Finance reply resumes the interrupted graph, and the customer receives the decision by email.
See the refund approval workflow
The recording follows the complete example: an email starts the workflow, LangGraph pauses for the three required approvals, each authenticated WhatsApp response resumes its own interrupt, and the approved result is delivered back to the customer by email.
When a workflow should use separate source and destination
Separate source and destination providers are appropriate when routing is a workflow decision rather than an inherent response:
- an ERP order event alerts a sales team over WhatsApp;
- an email refund request asks HR, a manager, and Finance for approval;
- a personal assistant reads calendar or email events and sends a WhatsApp briefing.
If a WhatsApp message simply needs a WhatsApp reply, use the smaller coupled WhatsApp workflow recipe. Both forms use the same Channel, run, thread, LangGraph, and outbox primitives; decoupled delivery is an additional routing shape, not a replacement.
1. Define the workflow's inbound source
A source verifies and parses only. composeRoutedChannel arms the existing durable callback path, so the source does not need a fake email deliver method. Both inbound providers use one trusted, tenant-scoped workflow ID when they intentionally converge on the same thread:
const tenantId = "acme"; // Deployment configuration, never inbound request data.
export function workflowThreadId(refundId: string): string {
return `relay:${tenantId}:${refundId}`;
}Here refundId is issued by the application or verified provider metadata; do not derive an authoritative thread ID directly from email text or another user-controlled field.
import type { Channel } from "@skein-js/channels";
import { z } from "zod";
import { workflowThreadId } from "./workflow-thread-id.js";
const refundEmailSchema = z.object({
messageId: z.string().min(1),
refundId: z.string().min(1),
from: z.string().email(),
orderId: z.string().min(1),
amountKes: z.number().positive(),
reason: z.string().min(1),
});
export const emailSource = {
name: "email-source",
verify(request) {
if (!verifyEmailWebhook(request)) return false;
return { identity: "channel:email:inbound" };
},
parseEvent(request) {
const email = refundEmailSchema.parse(request.json());
return {
kind: "event" as const,
event: {
threadKey: email.refundId,
threadId: workflowThreadId(email.refundId),
idempotencyKey: email.messageId,
input: {
source: "email",
refundId: email.refundId,
customerEmail: email.from,
orderId: email.orderId,
amountKes: email.amountKes,
reason: email.reason,
financeWhatsapp: "whatsapp:+254700000013",
},
},
};
},
} satisfies Pick<Channel, "name" | "verify" | "parseEvent">;2. Allowlist workflow destinations
Destination callbacks own credentials, provider validation, authorization, and idempotency. Names are explicit and local to this composed channel. The map allowlists provider adapters, not recipients: authorize every target from trusted application data rather than treating an LLM-selected or user-supplied phone number as permission to send.
import { composeRoutedChannel, type ChannelDestinationDelivery } from "@skein-js/channels";
import { z } from "zod";
const whatsappDeliverySchema = z.object({
target: z.object({ to: z.string().min(1) }),
payload: z.object({ body: z.string().min(1) }),
});
const emailDeliverySchema = z.object({
target: z.object({ to: z.string().email() }),
payload: z.object({ subject: z.string(), body: z.string() }),
});
export const destinations = new Map([
[
"whatsapp",
async (delivery: ChannelDestinationDelivery) => {
const message = whatsappDeliverySchema.parse(delivery);
await assertAuthorizedRecipient(delivery.threadId, "whatsapp", message.target.to);
await sendWhatsAppMessage(
{ to: message.target.to, body: message.payload.body },
{ key: delivery.runId },
);
},
],
[
"email",
async (delivery: ChannelDestinationDelivery) => {
const message = emailDeliverySchema.parse(delivery);
await assertAuthorizedRecipient(delivery.threadId, "email", message.target.to);
await sendEmail(
{
to: message.target.to,
subject: message.payload.subject,
body: message.payload.body,
},
{ key: delivery.runId },
);
},
],
]);
export const channel = composeRoutedChannel(emailSource, destinations);The map is copied at construction and is the complete adapter allowlist. Unknown destination names fail the durable outbox attempt instead of silently dropping or guessing a route. Recipient and operation policy remains application-owned; assertAuthorizedRecipient represents a lookup against trusted workflow or tenant data, not another check of the recipient's string shape.
3. Let LangGraph run the workflow and choose WhatsApp
LangGraph passes config.writer to the node. The graph declares data; the destination callback performs the external side effect only after the run settles.
import {
Annotation,
END,
interrupt,
START,
StateGraph,
type LangGraphRunnableConfig,
} from "@langchain/langgraph";
import { declareChannelDestinationDelivery } from "@skein-js/channels";
const State = Annotation.Root({
source: Annotation<"email">,
refundId: Annotation<string>,
customerEmail: Annotation<string>,
orderId: Annotation<string>,
amountKes: Annotation<number>,
reason: Annotation<string>,
financeWhatsapp: Annotation<string>,
financeDecision: Annotation<"approve" | "reject" | undefined>,
});
function notifyFinance(state: typeof State.State, config: LangGraphRunnableConfig) {
declareChannelDestinationDelivery(config.writer, {
destination: "whatsapp",
target: { to: state.financeWhatsapp },
payload: {
body:
`Approve KES ${state.amountKes.toLocaleString()} refund for ${state.orderId}? ` +
`Reason: ${state.reason}. Reply APPROVE or REJECT.`,
},
});
return {};
}
function awaitFinance(state: typeof State.State) {
const decision = interrupt({
kind: "refund-approval",
refundId: state.refundId,
assignedTo: state.financeWhatsapp,
});
return { financeDecision: decision as "approve" | "reject" };
}
function emailCustomer(state: typeof State.State, config: LangGraphRunnableConfig) {
const approved = state.financeDecision === "approve";
declareChannelDestinationDelivery(config.writer, {
destination: "email",
target: { to: state.customerEmail },
payload: {
subject: approved
? `Refund approved for ${state.orderId}`
: `Refund update for ${state.orderId}`,
body: approved
? `Finance approved your KES ${state.amountKes.toLocaleString()} refund.`
: "Finance could not approve this refund. Our support team will contact you.",
},
});
return {};
}
export const graph = new StateGraph(State)
.addNode("notify-finance", notifyFinance)
.addNode("await-finance", awaitFinance)
.addNode("email-customer", emailCustomer)
.addEdge(START, "notify-finance")
.addEdge("notify-finance", "await-finance")
.addEdge("await-finance", "email-customer")
.addEdge("email-customer", END)
.compile();On the first run, notify-finance supplies the durable WhatsApp delivery and await-finance parks the thread. The WhatsApp approval webhook needs its own verified source channel, must address the same trusted workflow thread, and resumes the interrupt:
import { composeRoutedChannel, type Channel } from "@skein-js/channels";
import { z } from "zod";
import { destinations } from "./destinations.js";
import { workflowThreadId } from "./workflow-thread-id.js";
const financeReplySchema = z.object({
messageId: z.string().min(1),
refundId: z.string().min(1),
interruptId: z.string().min(1),
from: z.string().min(1),
decision: z.enum(["approve", "reject"]),
});
const whatsappSource = {
name: "whatsapp-source",
verify(request) {
if (!verifyWhatsAppWebhook(request)) return false;
const message = financeReplySchema.parse(request.json());
return { identity: `channel:whatsapp:${message.from}` };
},
parseEvent(request) {
const message = financeReplySchema.parse(request.json());
return {
kind: "event" as const,
event: {
threadKey: message.refundId,
threadId: workflowThreadId(message.refundId),
idempotencyKey: message.messageId,
input: { source: "whatsapp", ...message },
resumeWith: { [message.interruptId]: message.decision },
},
};
},
} satisfies Pick<Channel, "name" | "verify" | "parseEvent">;
export const channel = composeRoutedChannel(whatsappSource, destinations);workflowThreadId must derive a tenant-scoped ID from trusted workflow data, and both sources must use the same function. Derive the Finance principal from the verified WhatsApp sender and validate that the principal is assigned to this approval before accepting the decision. The runnable example performs both checks and keeps the actor, provider event ID, and timestamp in graph state as an audit trail.
Only declareChannelDestinationDelivery triggers a routed destination. Ordinary replyWith output and inferred AI replies are ignored here, preventing an accidental chat response from becoming an external action. target and payload must be JSON-persistable; the destination validates their provider-specific shape.
4. Configure the workflow's source routes
{
"graphs": { "relay": "./src/relay-graph.ts:graph" },
"skein": {
"channels": {
"email": {
"path": "./src/email-channel.ts:channel",
"assistant": "relay",
"public_url": "https://api.example.com",
},
"whatsapp": {
"path": "./src/whatsapp-channel.ts:channel",
"assistant": "relay",
"public_url": "https://api.example.com",
},
},
},
}Configured route keys and explicit source names must be unique. Skein rejects those collisions at boot so a delivery alias cannot resolve to the wrong channel. That does not namespace an explicit threadId: this recipe intentionally shares one between email and WhatsApp. Build explicit IDs from trusted, tenant-scoped workflow identifiers; when threadId is omitted, Skein safely namespaces the derived ID by channel name instead.
The complete workflow is:
Customer email → LangGraph → Finance WhatsApp → interrupt/resume → LangGraph → customer emailFor conditional email/WhatsApp routing plus parallel LangGraph interrupt() approvals, run examples/decoupled-delivery.