Chat transport
@flow-state-dev/chat-sdk turns a Vercel Chat SDK bot into an inbound
transport. Wrap one Chat instance and every platform it serves — Slack,
Microsoft Teams, Google Chat, Discord — drives your flows. Inbound messages
become action invocations; flow output streams back to the originating
thread.
If you're building anything past a single-flow demo, the part that matters is this: a flow declares which chat events trigger which handler, directly on the flow definition. Each binding carries its handler inline (the shared action core), so you read one file to know what fires it and what runs.
This is the transport for real-time conversation. For asynchronous service-to-service notifications — Slack's Events API, Stripe, GitHub — use the webhook transport instead.
Install and mount
pnpm add @flow-state-dev/chat-sdk chat
import { Chat } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createFlowState, inMemoryStores } from "@flow-state-dev/engine";
import { createChatTransportAdapter } from "@flow-state-dev/chat-sdk";
const bot = new Chat({
userName: "fsd-bot",
adapters: { slack: createSlackAdapter({ token: process.env.SLACK_BOT_TOKEN! }) },
});
export const flowstate = createFlowState({
flows: { support: supportFlow },
stores: { default: inMemoryStores() },
adapters: [createChatTransportAdapter({ bot })],
});
createFlowState is the canonical entrypoint (see Server setup); the chat transport is just another entry in adapters. Turn the handle into route handlers with a platform adapter — the chat webhooks mount under the same router:
import { flowstate } from "@/lib/flowstate";
import { createVercelNextHandler } from "@flow-state-dev/vercel/next";
export const { GET, POST, PATCH, DELETE } = createVercelNextHandler(flowstate);
The adapter mounts POST /api/chat/slack and GET /api/chat/slack (the GET
handles platforms that use challenge-response verification). The mount takes
just { bot } — routing lives on the flows. An inbound event that matches no
subscription is acknowledged and dropped.
Declaring subscriptions on the flow
Put a chat.on map on the flow. Each key is a chat event kind; each value
binds it to a handler and says how the event maps to that handler's input.
A binding carries the handler inline (the shared action core) instead of
naming an entry in flow.actions. Same model the webhook transport uses. An
event-addressed handler lives only on chat.on, so it has no HTTP or MCP
caller surface. If you want one block reachable both as a named action and as
a chat handler, declare it in both places (same block reference).
import { defineFlow } from "@flow-state-dev/core";
import { defineChatBinding } from "@flow-state-dev/chat-sdk";
const supportFlow = defineFlow({
kind: "support",
chat: {
on: {
mention: defineChatBinding({
block: replyBlock,
input: (event) => ({ text: event.message?.text ?? "" }),
}),
reaction: defineChatBinding({
block: escalateBlock,
when: (event) => event.platform === "slack",
input: (event) => ({ emoji: event.actionValue }),
}),
},
},
});
A binding carries the action core plus the event mapping:
block— the handler to run for the event. Required.input— maps the event to the handler's input. May be async.sessionId(optional) — derives the session id from the event. May be async. Defaults to the originating thread's id.when(optional) — a synchronous predicate. A falsy result skips the binding; other bindings still evaluate.
It also accepts the rest of the action core: durable, tokenBudget,
onCompleted / onErrored, inputSchema, userMessage. The dispatched
request records the handler block's name as its action (provenance only — the
handler is never reachable through the action endpoint).
Typed events with defineChatBinding
defineChatBinding is a typing convenience: it gives the event parameter
a ChatInboundEvent type instead of unknown. It does nothing at runtime —
a plain object literal works just as well, you just lose the event type. The
helper lives in @flow-state-dev/chat-sdk (not core) so the core package
stays independent of the chat-sdk.
How dispatch works
At mount the adapter walks the flow registry once and indexes every
chat.on binding by event kind. For each inbound event it looks up the
matching bindings, filters them by when, and dispatches.
- Fan-out is broadcast. Two flows subscribing to the same event both run, independently.
- No match is a no-op ack. An event with no matching subscription is acknowledged and dropped. No event throws at dispatch.
Event kinds
Keys match ChatInboundEvent.kind exactly:
mention, subscribedMessage, directMessage, messageMatch, reaction,
action, slashCommand, modalSubmit, assistantThreadStarted,
memberJoined, custom.
The vocabulary is uniform across platforms — a mention binding fires on
Slack, Teams, and Discord alike. To scope a binding to one platform, use
when: (e) => e.platform === "slack". Two notes for this release:
messageMatch is reserved but not yet wired to a handler, and custom
events carry only kind and platform, so narrow inside input/when.
Streaming back to the thread
Flow output pipes back to the originating thread by default. Turn it off
per-flow with chat.streamToThread: false. The effective value resolves in
order: the flow's chat.streamToThread, then the adapter-mount
flowOverrides[kind].streamToThread, then the adapter's streamToThread,
defaulting to true.
Migrating from adapter-mount routing
Earlier versions let the adapter mount carry a route(event) callback or a
static flowKind/action pair. Those options are gone. Chat is purely
declarative on: now, the same as webhooks.
Re-express imperative or content-based routing as chat.on bindings with
when predicates. A route() that inspected the event and picked a flow
becomes one binding per target, each gated by the condition the old callback
checked:
// Before: imperative routing at the mount.
createChatTransportAdapter({
bot,
route: (event) => ({ flowKind: "legacy", action: "chat", input: event.raw }),
});
// After: a declarative binding on the flow it routed to.
const legacyFlow = defineFlow({
kind: "legacy",
chat: {
on: {
mention: defineChatBinding({
block: chatBlock,
input: (event) => event.raw,
}),
},
},
});
Whatever the old route() returned skip for becomes an event with no
matching binding: a no-op ack.
Testing
The package ships mocks under @flow-state-dev/chat-sdk/testing for
exercising flows without a real bot. To unit-test subscription dispatch,
build the subscription index and drive events through the dispatch path
directly — see the package's dispatch.test.ts for the pattern.
Limitations
- No wildcard event matching and no first-match-wins (exclusive) semantics.
whenis synchronous only; for async filtering, let the action run and reject inside it.- Subscriptions are snapshotted at mount; hot reload is out of scope.