Skip to main content

Inbound Transports

The server runtime accepts requests through one or more inbound transports. The default deployment exposes a single HTTP entry point — that's what you see when you wire createFlowApiRouter into a Next.js catch-all route. Other transports (MCP servers, webhooks, scheduled actions) plug in as siblings of HTTP.

Why one contract

Before this contract existed, every new way of driving a flow had to re-invent the auth pipeline, the principal resolution, the dispatch machinery. That worked for the first transport. It scaled badly past one.

With the contract, every entry point looks the same to the runtime. A transport translates whatever it receives — an HTTP request, an MCP tools/call, a webhook POST, a cron tick — into an InboundRequestEnvelope and hands it to the host. The host owns the runtime; the transport owns its protocol.

What an adapter looks like

import type { InboundTransportAdapter } from "@flow-state-dev/server";

export function createEchoAdapter(): InboundTransportAdapter {
return {
source: "echo",
createBindings(host) {
return {
routes: [
{
method: "POST",
path: "/api/flows/echo",
handler: async (req) => {
const body = await req.json();
const principal = await host.resolvePrincipal({
source: "echo",
request: req,
envelope: {
flowKind: body.flowKind,
action: body.action,
input: body.input,
metadata: { body }
}
});
const handle = host.dispatch({
source: "echo",
flowKind: body.flowKind,
action: body.action,
input: body.input,
principal
});
const result = await handle.finished;
return new Response(JSON.stringify(result), { status: 200 });
}
}
]
};
}
};
}

Two things matter here.

source is provenance. Every request the adapter dispatches carries it through to the RequestRecord and surfaces in DevTool as a small badge next to the action. The known values are http, mcp, webhook, scheduled, notification — pick your own for custom transports, the framework does not enforce an enum.

Known sources

ValueUsed by
httpThe default HTTP adapter
mcpMCP server adapter (@flow-state-dev/mcp)
webhookWebhook receivers
scheduledScheduled dispatch (@flow-state-dev/scheduled)
notificationCross-flow event subscribers

DevTool renders known sources with a label and a small badge. Custom transport sources fall back to the raw string.

host.dispatch is fire-and-forget. It returns a synchronous handle whose liveStream and requestId are available immediately, while finished resolves when the action completes. Adapters that need a streamed response consume handle.liveStream.readable. Adapters that only want the final result await handle.finished.

Mounting a custom adapter

const router = createFlowApiRouter({
registry,
stores,
adapters: [createEchoAdapter()]
});

The default HTTP adapter is always mounted; adapters adds extras. Routes from every adapter merge into the returned { GET, POST, PATCH, DELETE } dispatcher. If two adapters declare the same (method, path) pair, the router throws TransportRouteCollisionError at construction time — better than ambiguous runtime dispatch.

Scheduled adapter shape

@flow-state-dev/scheduled is the second concrete adapter after MCP. It mounts POST /api/flows/:flowKind/schedules/:scheduleId/dispatch and a sibling GET for listing static schedules. Dispatch is fire-and-forget: the adapter constructs an envelope with responseEmitter: null and returns 202 the moment host.dispatch returns the handle. The action runs to completion under the framework runtime and surfaces in DevTool like any other request.

Dynamic schedules are resolved at dispatch time via a hook on the flow definition (schedules.resolve(scheduleId, ctx)). The framework does not own schedule storage — the host backs the hook with a flow-state resource collection, a database table, or an external service.

Auth is two-phase. The dispatch endpoint runs through host.resolvePrincipal like every other route to establish the gateway principal (typically a system user proven via a shared scheduler secret). Each schedule then carries its own principal, which wins over the gateway principal when the action runs. Static framework-level schedules usually omit it; per-user dynamic schedules synthesize it from the schedule's owner so the action runs as the right user.

source: "scheduled", metadata.scheduleId, metadata.origin ("static" or "dynamic"), metadata.cron, metadata.nominalFireTime, metadata.dispatchedAt, and metadata.timezone propagate to RequestRecord for trace and DevTool. See Scheduled actions for the full surface.

Auth

Every adapter calls host.resolvePrincipal before constructing an envelope. Per-flow defineFlow({ authentication }) wins over the host-level fallback configured on createFlowApiRouter({ resolvePrincipal }), which itself defaults to reading body.userId from the parsed HTTP body. Adapters never implement auth themselves — see the Authentication page for the resolver contract, requireUser semantics, and the bundled HMAC and JWT helper utilities.

Per-registry, not per-flow

Adapters mount onto a host built from one FlowRegistry. One adapter serves every flow in that registry. Per-flow opt-in (e.g. "expose only flow X over MCP") lives on the flow definition, not the adapter shape.

Conformance

@flow-state-dev/testing exports a conformance suite. Run it against your adapter and you've validated the contract:

import { createInboundTransportConformanceTests } from "@flow-state-dev/testing";

createInboundTransportConformanceTests({
name: "myAdapter",
factory: () => createMyAdapter(),
helpers: {
buildEnvelope: async (adapter, host) => {
// your envelope construction here
}
}
});

The HTTP adapter is the first conforming implementation.