Reference

Flow-driven HTTP endpoints

Mount FlowDSL flows as HTTP routes. Domain modules contribute nodes; flowexec owns the HTTP↔flow bridge. Schemas attach to edges as packets (one ref → three rendered forms: stored, /openapi.json, Studio); the http-endpoint source node carries only routing + spec metadata. Flow templates ship in per-module flowtemplates/ packages and auto-publish at startup so endpoints work out of the box.

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

SituationSolution
One-off CRUD on a fixed schemaRegular 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

text
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)
PieceWherePurpose
redelay/http-endpoint source nodego-flowdsl/nodes/module.yamlDeclarative spec — path, method, schemas, auth flag, summary
Dispatchergo-flowdsl/flowexec/module/router/Route registry + chi middleware. Bind on flow publish, Unbind on unpublish
DynamicRoutesProvidergo-framework/modules/Reusable contract. Any module exposing dynamic routes implements it; openapi merges them into /openapi.json
DynamicChannelsProvidergo-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

yaml
- 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)

json
{
  "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:

  1. Define packets once at meta.packets (workflow-level). Use a $ref to the canonical Go-struct schema published in /openapi.json so the type stays single-sourced — never duplicate JSON Schema in the template.
  2. Reference packets on edges via packet: "#/components/packets/<Name>".
  3. Add an explicit terminal node (typically named end) 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

text
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:

ConsumerFormWhy
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:

go
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:

json
{
  "<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):

KeyTypePurpose
status_codeintHTTP status. Defaults to the node's success_status (201 for signup-style endpoints).
bodyanyJSON-serialisable response body. When absent, every key in the output map except status_code/headers/error becomes the body.
headersmap[string]stringExtra response headers, applied before WriteHeader.
errorstringWhen 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.Module collects:
    • 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)
  • asyncapi.Module collects:
    • declared events via EventsProvider.Events() (existing)
    • flow-emitted/consumed channels via DynamicChannelsProvider.DynamicChannels() (new — implemented by flowexec)

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:

go
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):

go
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:

json
{
  "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):

json
{
  "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:

text
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

SubpackageContentsBlank-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 pathsadmin-api binary only
<module>/flowdsl/FlowDSL nodes (operators) + handlers calling parent Servicebinaries that participate in flows
<module>/flowtemplates/starter flow compositions + auto-publish-on-startup bootstrapbinaries 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

text
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 IDBound atPipeline
users/registration-basicPOST /api/v1/users/signuphttp → users-create-user → end (201)
users/registration-email-confirmPOST /api/v1/users/signuphttp → users-create-pending → verification-request-email → end (202)
users/registration-sms-confirmPOST /api/v1/users/signuphttp → users-create-pending → verification-request-sms → end (202)
users/registration-dual-confirmPOST /api/v1/users/signuphttp → 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

MethodPathPurpose
GET/api/v1/admin/flows/templates?source=usersList the 6 starter templates (filter applied client-side)
GET/api/v1/admin/users/registration/activeRead flow currently serving /api/v1/users/signup
POST/api/v1/admin/users/registration/activate-templateOne-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_schema and response_schema as JSON-Schema-typed settings on the redelay/http-endpoint node. 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's inlinePacketRefs (for Studio's ?format=spec) are the only two pieces of code that know about the openapi: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_PREFIX had 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 respond node. The convention (status_code, body, headers, error keys 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 set body explicitly; minimalist authors dump fields. Both work.
  • _meta envelope vs. flat input. Body fields at top level keeps flow expressions short ({{.email}} not {{.body.email}}). Request metadata under a single _meta namespace prevents collisions with body fields named method, 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 _meta is 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.