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.
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.
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.