Flow-driven HTTP endpoints
Flow-driven HTTP endpoints
Any FlowDSL flow whose source is a redelay/http-endpoint node is
mounted as an HTTP route by the framework. When the configured path is
hit, the flow runs synchronously, and its terminal output is written
as the JSON response. Domain modules don't write HTTP-handler shims —
they contribute nodes, and the flowexec dispatcher owns the bridge.
When to use this
| Situation | Solution |
|---|---|
| One-off CRUD on a fixed schema | Regular Go RoutesProvider handler |
| Multi-step business pipeline (signup, password reset, OAuth callback, account deletion, …) | Flow-driven endpoint + per-module starter templates |
| Different deployments need different pipelines (e.g. add SMS confirmation only for paid tier) | Flow-driven endpoint — admins activate a different template per environment |
| Pure async event reaction (no HTTP request involved) | redelay/event-source node, no http-endpoint |
The fine-grained pipeline cases are why users/signup is flow-driven
in the framework. The flat-CRUD cases (/users/me, /admin/users/*)
stay Go-handled.
Components
chi router
└── flowexec dispatcher middleware (registered ahead of normal routes)
│
├── matched (method, path)? → run flow synchronously,
│ write output as JSON response
│
└── no match? → fall through to next handler (regular Go routes)
| Piece | Where | Purpose |
|---|---|---|
redelay/http-endpoint source node | go-flowdsl/nodes/module.yaml | Declarative spec — path, method, schemas, auth flag, summary |
| Dispatcher | go-flowdsl/flowexec/module/router/ | Route registry + chi middleware. Bind on flow publish, Unbind on unpublish |
DynamicRoutesProvider | go-framework/modules/ | Reusable contract. Any module exposing dynamic routes implements it; openapi merges them into /openapi.json |
DynamicChannelsProvider | go-framework/modules/ | Same idea for AsyncAPI events emitted/consumed by flows |
Per-module flowtemplates/ package | <module>/flowtemplates/ | Starter compositions + auto-publish-on-startup |
The http-endpoint node
- id: http
kind: source
ref: redelay/http-endpoint
config:
path: /api/v1/users/signup # exact-match path
method: POST # GET|POST|PUT|PATCH|DELETE
success_status: 201 # default 200; per-call override via output.status_code
auth_required: false # see Auth gate below
summary: Public signup # → openapi summary
description: … # → openapi description
tags: [users, public] # → openapi tags
Routing + spec metadata only. The node carries the route coordinates, the auth gate, and the documentation strings — nothing schema-shaped. Request and response bodies are typed via packet refs on edges (see next section), not via inline JSON Schema on this node.
Output port: request — emits the merged-input map described
under Flow input shape below. Downstream nodes
read body fields directly ({{.email}}); request metadata lives at
{{._meta.*}}.
The node is purely declarative. The dispatcher does the actual
HTTP↔flow translation; the node's settings are read at flow publish
time to populate the Binding record.
Packet-based schema model
Every flow that's mounted as an HTTP endpoint declares its
request/response shapes as packets — first-class document-level
type definitions referenced from edges. This is the same shape used
by every other flow in the system (the assistant template's
ChatRequest / ChatResponse / GuardRejection are packets); HTTP
flows are not special.
Authoring (IR JSON template)
{
"id": "users-registration-basic",
"nodes": [
{"id": "http", "kind": "start", "action_ref": "redelay/http-endpoint",
"config": { "path": "/api/v1/users/signup", "method": "POST", "success_status": 201 }},
{"id": "create", "kind": "action", "action_ref": "redelay/users-create-user"},
{"id": "end", "kind": "terminal"}
],
"edges": [
{"from": "http", "to": "create", "packet": "#/components/packets/UserCreateInput"},
{"from": "create", "to": "end", "packet": "#/components/packets/UserResponse"}
],
"meta": {
"packets": {
"UserCreateInput": { "$ref": "openapi:default#/components/schemas/UserCreateInput" },
"UserResponse": { "$ref": "openapi:default#/components/schemas/UserResponse" }
}
}
}
Three rules:
- Define packets once at
meta.packets(workflow-level). Use a$refto the canonical Go-struct schema published in/openapi.jsonso the type stays single-sourced — never duplicate JSON Schema in the template. - Reference packets on edges via
packet: "#/components/packets/<Name>". - Add an explicit
terminalnode (typically namedend) so the response packet has somewhere to attach. The dispatcher reads the packet on the incoming edge to a terminal as the response schema.
Authors who want to skip the named-packet declaration can still write
packet: "openapi:default#/components/schemas/<Name>" directly on
the edge — the dispatcher passes external refs through unchanged.
Named packets are preferred when the same type is reused on multiple
edges (every assistant-template edge that carries a ChatRequest).
Schema lifecycle
template (IR JSON) users.OpenAPISchemas() {…} Go struct
│ meta.packets.UserCreateInput │ UserCreateInput
│ = {$ref: "openapi:default#/…"} │
▼ ▼
flow document in MongoDB /openapi.json#/components/schemas/UserCreateInput
│ │ (full reflected schema)
▼ │
┌───────────────────────────────────────┐ │
│ ExtractBinding (router/extract.go) │ │
│ - walks edges │ │
│ - resolves packet refs │ │
│ - strips `openapi:default#` prefix │ │
│ - emits in-document refs │ │
└──────────────┬─────────────┬──────────┘ │
│ │ │
▼ ▼ │
DynamicRouteDescriptor ──────────────────────► /openapi.json
{ RequestSchema: {$ref: "#/components/schemas/UserCreateInput"} }
▲
│
Scalar / Swagger UI ──────────┘ resolves locally
versionAsSpec (admin/handler.go) ?format=spec
- calls openapi.GenerateSpec to fetch live components.schemas
- inlines each `openapi:default#` packet ref with the resolved schema
─► Studio /flowdsl receives self-contained {type, properties, required}
The same packet ref serves three consumers, each in the form they expect:
| Consumer | Form | Why |
|---|---|---|
| Flow document (in DB) | {$ref: "openapi:default#/…"} | Single source of truth in Go types; templates stay thin |
/openapi.json request/response bodies | {$ref: "#/components/schemas/X"} | Standard OpenAPI in-document refs; Scalar / Swagger UI / SDK gen all resolve locally |
/api/v1/flows/{id}/versions/{vid}?format=spec (Studio) | {type, properties, required, …} (fully inlined) | Studio's PacketsPanel renders fields directly; "0 fields" gone |
The openapi:default# namespace
openapi:default#/components/schemas/UserCreateInput is a Redelay/
Studio-internal namespace used so modspec UIs can disambiguate
multiple loaded specs (openapi:default, asyncapi:default, future
named specs). The dispatcher strips this prefix when emitting refs
into /openapi.json — leaving it in would produce
"Could not resolve reference: Failed to fetch" in any
standards-compliant tool. See stripSpecNamespace() in
go-flowdsl/flowexec/module/router/extract.go.
Schema source: SchemasProvider
Modules whose Go types are referenced by flow packets must expose
those types so they end up in /openapi.json#/components/schemas.
Implement modules.SchemasProvider:
func (m *Module) OpenAPISchemas() []any {
return []any{
&UserCreateInput{},
&UserUpdateInput{},
&UserResponse{},
&UserListResponse{},
}
}
Each value is reflected via the same openapi.SchemaRegistry path
that Body() / Response() use; the schema lands in
components/schemas keyed by the struct name. Without this contract
a flow whose packet refs UserCreateInput would render
"Schema not found in loaded specs" in Studio and 404 the schema in
/openapi.json. Modules that already declare types via Go HTTP
handlers (Body/Response reflection) don't need to duplicate them
here — only types referenced only by FlowDSL nodes / packets do.
Flow input shape
The dispatcher prepares the workflow's start payload as a single
map[string]any:
{
"<every JSON body field at top level>": "...",
"_meta": {
"method": "POST",
"path": "/api/v1/users/signup",
"query": { "ref": "ad" },
"headers": { "X-Whatever": "..." },
"remote": "1.2.3.4:5678"
}
}
Body merging. When the request body is JSON, every top-level field
is merged into the input root so flow templates can write
{{.email}} instead of {{.body.email}}. Non-JSON bodies are not
inspected (forms / multipart / binary not supported in v1).
Header redaction. Authorization and Cookie are stripped from
_meta.headers so flow logs and node-output payloads (which can be
inspected via the run-events SSE stream) never contain credentials.
Flows that need to read the bearer token must use a dedicated auth
node instead of pulling it from _meta.
Body cap. Hard 1 MiB limit on the request body. Larger requests
return 413 before the flow runs. Bump per-route via a future
max_body_bytes node setting (not in v1).
Output convention
The flow's terminal node populates the workflow output map. The dispatcher recognises four well-known keys (all optional):
| Key | Type | Purpose |
|---|---|---|
status_code | int | HTTP status. Defaults to the node's success_status (201 for signup-style endpoints). |
body | any | JSON-serialisable response body. When absent, every key in the output map except status_code/headers/error becomes the body. |
headers | map[string]string | Extra response headers, applied before WriteHeader. |
error | string | When non-empty AND status_code is unset, the dispatcher returns 400 with {"error":..., "message":<value>}. |
This is enough to express the canonical signup outcomes without a
separate "respond" node. Flow authors who want a structured response
just set body directly; minimalist authors dump fields and accept
the 201 default.
Run-failure mapping. If the flow itself fails (handler returns
non-nil error, no terminal node fires, executor times out), the
dispatcher returns 500 with the flowexec sink owning the actual error
detail. The HTTP response body is {"error":"Internal Server Error","message":"flow execution failed"}
— the caller does not see the stack trace; ops triages via the run
log in Studio.
Auth gate
auth_required: true on the http-endpoint node makes the dispatcher
reject requests that arrive without an Authorization header (returns
401, flow does not run, Runs() counter does not increment). The
dispatcher itself does not validate the JWT — it trusts the
upstream auth.AuthMiddleware (registered ahead of the dispatcher in
the chain) to populate request context with claims when the token is
valid. Flows that need claims-aware logic read them from a dedicated
auth-claims node, not from _meta.
Granular options (require_superuser, require_permission) are
deferred. Add them as additional node settings when a real use case
arrives — both are additive and don't break the v1 contract.
Route shadowing
Published flow routes shadow Go-registered routes at the same (method, path). The dispatcher middleware sits ahead of chi's main matcher; on match, the flow runs and the Go handler is never invoked. On unpublish (or flow delete), the binding is removed and the Go handler resumes serving the route — no restart required.
This is why users/signup doesn't have a Go handler at all: the
framework auto-publishes a default registration template at first
startup, so /api/v1/users/signup is always flow-driven, but the
specific flow can be swapped without touching code.
Conflicts between two flows wanting the same (method, path) are
rejected: Dispatcher.Bind returns an error when the path is already
bound to a different flow ID. Admins resolve by unpublishing the
existing binding first.
Spec discoverability
Both /openapi.json and /asyncapi.json are spec contributors:
openapi.Modulecollects:- regular routes via
RoutesProvider.Routes()(existing) - flow-mounted routes via
DynamicRoutesProvider.DynamicRoutes()(new — implemented by flowexec) - schema types via
SchemasProvider.OpenAPISchemas()(new — for types referenced only by FlowDSL packets)
- regular routes via
asyncapi.Modulecollects:- declared events via
EventsProvider.Events()(existing) - flow-emitted/consumed channels via
DynamicChannelsProvider.DynamicChannels()(new — implemented by flowexec)
- declared events via
Flow-mounted routes carry an x-source: "flow:<flowID>" extension on
their OpenAPI path entries so SDK generators / clients can identify
them. Their request and response schemas come from the packet refs
on flow edges — see Packet-based schema model
above. The dispatcher derives them at flow-publish time (in
ExtractBinding) and stores them on the binding; the openapi
generator reads bindings on every /openapi.json request.
The merge happens at request time (when admin hits /openapi.json),
not at boot — so newly published flows show up in the spec immediately
without a restart.
Idempotent route prefixing
ROUTE_PREFIX (default /api/v1) is applied only to relative
module-route paths. Dynamic routes carry the full absolute path on
their DynamicRouteDescriptor.Path field — the dispatcher binds chi
at that exact path with no prefix added at routing time. The openapi
service (go-framework/modules/openapi/service.go) checks
strings.HasPrefix(path, m.routePrefix+"/") and skips re-prefixing
when the path is already absolute. Without this, the spec advertised
/api/v1/api/v1/users/signup while the route actually lived at
/api/v1/users/signup, producing 404s for any client that trusted
the spec.
Routes outside the prefix — AbsolutePath
The HasPrefix check handles a route that is already prefixed, but
not one that genuinely lives outside ROUTE_PREFIX — a top-level
mount such as /ws/health, which does not start with /api/v1 and
would otherwise be re-prefixed to /api/v1/ws/health while its handler
stays at /ws/health. Set AbsolutePath: true on the descriptor for
these:
func (m *Module) DynamicRoutes() []modules.DynamicRouteDescriptor {
return []modules.DynamicRouteDescriptor{{
Method: "GET", Path: "/ws/health",
Source: "ws", SuccessStatus: 200,
AbsolutePath: true, // emit verbatim, never prefix
}}
}
AbsolutePath carries an internal x-absolute-path marker through
spec generation; the prefixer leaves the path untouched and strips the
marker before the document is served. The WebSocket
hub is the first consumer.
Auto-publish bootstrap
A module that ships starter templates can auto-publish a default at
first startup so its endpoint works out of the box. Pattern (used by
users/flowtemplates):
func (m *Module) Startup(ctx context.Context) error {
fx := flowexecmod.Current() // nil-safe
if fx == nil {
return nil
}
_, _, _ = fx.EnsurePublishedAtPath(ctx,
"POST", "/api/v1/users/signup",
"users/registration-basic", // template ID
)
return nil
}
EnsurePublishedAtPath is idempotent: if any flow is already bound
at the (method, path), it leaves the existing binding alone. So a
project that has activated a different template (email-confirm,
sms-confirm, custom) won't have it overwritten on restart.
Template dependencies — meta.publish_with
Some flow-driven endpoints can't complete their request/response cycle
on their own — they need an event-driven handler running alongside.
Confirmation-style signup is the canonical case: the registration flow
creates an inactive user and dispatches a verification token, then
hands off to an event handler that flips is_active=true when the
user clicks the link.
A template declares those runtime dependencies in its IR meta:
{
"id": "users-registration-email-confirm",
...
"meta": {
"publish_with": ["users/email-verify-handler"]
}
}
When ActivateTemplate runs, it walks publish_with and calls
EnsureTemplatePublished on each — idempotent, reuses already-running
handlers. This is how confirmation signup ships intact: one click
on the activation button publishes BOTH the registration flow AND the
matching verify handler.
The activation response echoes which dependencies were freshly published (already-running ones are silent so re-activations stay quiet):
{
"flow_id": "flow.…",
"template_id": "users/registration-email-confirm",
"method": "POST",
"path": "/api/v1/users/signup",
"published_with": ["users/email-verify-handler"]
}
Subscriber deployments
Templates without an http-endpoint source — typically event-driven
handlers — get wrapped in a subscriber deployment when published
via EnsureTemplatePublished:
flowexec.EnsureTemplatePublished(ctx, "users/email-verify-handler")
│
├── publishes flow with x-template-id label
├── ensureSubscriberDeployment(...)
│ ID: subscriber-users-email-verify-handler
│ Labels: x-kind=subscriber, x-event=verification.token_verified,
│ x-event-filter=payload.kind == "email"
│ x-template-id=users/email-verify-handler
└── stable variant points at the freshly-published flow
Same lifecycle contract as route deployments — idempotent ID, disable/enable toggle, canary/A-B variants — but routing happens via the event bus instead of HTTP. Multiple subscriber deployments listening to the same event channel is legitimate fan-out (route uniqueness doesn't apply here).
See Deployments for the full
deployment-layer reference, including the disabled flag, route
uniqueness invariant, spec contributions, and admin endpoints.
Module package convention
| Subpackage | Contents | Blank-imported by |
|---|---|---|
<module>/ | core: Service, model, settings, module-owned Go HTTP handlers (only flat CRUD-shaped) | every binary that needs the module |
<module>/admin/ | admin endpoints, masking, write paths | admin-api binary only |
<module>/flowdsl/ | FlowDSL nodes (operators) + handlers calling parent Service | binaries that participate in flows |
<module>/flowtemplates/ | starter flow compositions + auto-publish-on-startup bootstrap | binaries that want opinionated defaults |
All four are independently optional. A project can take core+admin without flowdsl (no flow integration), or core+flowdsl without flowtemplates (use my own flows), or all four (full opinionated experience).
Reference: users registration endpoint
The first end-to-end consumer of this infrastructure is the users
module's /api/v1/users/signup. Captured here as a worked example of
how a domain module flips its public endpoint to flow-driven without
touching the framework HTTP layer.
Architecture
admin (UI) users-flowtemplates flowexec
│ │ │
│ POST /admin/users/ │ │
│ registration/ │ │
│ activate-template │ │
├─────────────────────────►│ ActivateTemplate(...) │
│ ├─────────────────────────►│ unbind old
│ │ │ create flow
│ │ │ publish version
│ │ │ rebind
│ ◄────────────────────────┴──────────────────────────┤ flow_id
│ │
│ POST /api/v1/users/signup ──────────────────────────►│ dispatcher matches
│ │ runs flow
│ │ ┌─ http-endpoint (source)
│ │ ├─ users-create-pending ┐
│ │ └─ verification-request-email
│ ◄───────────────────────────────────────────────────┤ output → 202 + body
Six templates ship in users/flowtemplates/
| Template ID | Bound at | Pipeline |
|---|---|---|
users/registration-basic | POST /api/v1/users/signup | http → users-create-user → end (201) |
users/registration-email-confirm | POST /api/v1/users/signup | http → users-create-pending → verification-request-email → end (202) |
users/registration-sms-confirm | POST /api/v1/users/signup | http → users-create-pending → verification-request-sms → end (202) |
users/registration-dual-confirm | POST /api/v1/users/signup | http → users-create-pending → verification-request-email → verification-request-sms → end (202) |
users/email-verify-handler | (event-driven) | event-source(verification.token_verified, kind=email) → users-activate |
users/sms-verify-handler | (event-driven) | event-source(verification.token_verified, kind=phone) → users-find-by-email → users-activate |
All four HTTP-mounted templates declare two packets at meta.packets
— UserCreateInput (request) and UserResponse (response) — both
$ref-ing the openapi:default namespace. The user types come from
users.Module.OpenAPISchemas().
Admin endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/admin/flows/templates?source=users | List the 6 starter templates (filter applied client-side) |
GET | /api/v1/admin/users/registration/active | Read flow currently serving /api/v1/users/signup |
POST | /api/v1/admin/users/registration/activate-template | One-click switch — unbinds old flow + publishes new from template |
Auto-publish bootstrap
On first startup, users-flowtemplates calls
flowexec.EnsurePublishedAtPath("POST", "/api/v1/users/signup", "users/registration-basic"). The call is idempotent: if any flow is
already bound at that path (admin-activated or auto-published on a
prior boot), the call no-ops and the existing binding survives. Set
USERS_AUTO_PUBLISH_TEMPLATE to override the default; set
USERS_AUTO_PUBLISH_DISABLED=true to skip entirely.
Backward compatibility
Pre-2026-04 the users module owned handleSignup directly. The
removal is a breaking change in the strict sense — the Go function
no longer exists — but the public HTTP contract (POST /api/v1/users/signup) is preserved by users/registration-basic
auto-publishing on first boot. Projects that had
USERS_EMAIL_CONFIRMATION_ENABLED=true should set
USERS_AUTO_PUBLISH_TEMPLATE=users/registration-email-confirm to
match prior behavior; everything else continues working without
changes.
Why these decisions
A few design choices captured for posterity:
- Packets on edges, not schemas on the source node. Earlier
iterations of this design carried
request_schemaandresponse_schemaas JSON-Schema-typed settings on theredelay/http-endpointnode. Two reasons that's gone: (1) it's inconsistent with every other node in the catalog (LLM, RAG, guard, …) where typed I/O is declared via input/output ports + edge packets; (2) Studio rendered the inline JSON as a[object Object]textarea because the field has no obvious editor for an arbitrary JSON Schema. The packet model puts schema attribution where it belongs (the type of the data flowing between two nodes) and Studio renders it like every other edge. - Single packet ref, three rendered forms. The dispatcher's
resolvePacket+stripSpecNamespace(for/openapi.json) and the admin module'sinlinePacketRefs(for Studio's?format=spec) are the only two pieces of code that know about theopenapi:default#namespace. Templates use the namespaced form (single source of truth, Studio resolves it via modspec cache); consumers each get the form they understand. Templates never duplicate Go-struct definitions. - Idempotent prefix.
ROUTE_PREFIXhad been blindly prepended to every path in the openapi response. That worked for module routes (declared without a prefix) but doubled dynamic-route paths. The fix — skip prepending when the path already starts with the prefix — is the safest possible change: it's a no-op for any module route declared the canonical way (without/api/v1/), and it stops doing the wrong thing for absolute paths. Module authors who hardcoded the prefix in their route declarations get the now-correct behavior automatically. - Output convention vs. dedicated
respondnode. The convention (status_code,body,headers,errorkeys on the workflow output map) keeps templates compact — a successful signup is{"id": user.id, "email": user.email}on a single end node, no ceremony. A dedicated respond node would force every template to carry one. Authors who want structure can still setbodyexplicitly; minimalist authors dump fields. Both work. _metaenvelope vs. flat input. Body fields at top level keeps flow expressions short ({{.email}}not{{.body.email}}). Request metadata under a single_metanamespace prevents collisions with body fields namedmethod,path,headers, etc. The redaction rule (Authorization + Cookie stripped) defaults to safe — flow output payloads land in the run-events SSE stream which inherits the framework's audit hygiene by accident if_metais dumped wholesale.- Auth gate as boolean. Granular variants (
require_superuser,require_permission: "...") are doable as additional node settings later. Booleans cover the immediate need (signup is public, account deletion is authenticated) and avoid defining a permission-string contract before a real use case lands. Both extensions are additive.
Profiles
Reusable, admin-editable config bundles — one dropdown on a FlowDSL node replaces eight hand-configured fields. Works for LLM chat, guard, databases, FTP, storage, any connection-bearing node.
Deployments
The dispatch unit between modules and flows. Wraps every published flow with a stable ID, canary/A-B variant routing, an enable/disable toggle, and a contract describing what the flow contributes to /openapi.json + /asyncapi.json.