Flow Templates
Flow Templates
Starter flows that show up in the FlowDSL Studio's Create flow → From template picker. Any module can ship one or more — the flowexec module discovers them by walking the registry and type-asserting every module to flowexec.TemplateProvider. No HTTP routes, no migrations, no extra wiring.
The interface
package flowexec // github.com/redelay/go-flowdsl/flowexec
type FlowTemplate struct {
// Stable handle. Conventionally `<module>/<slug>`.
// Used in the URL of `GET /flows/templates/{id}`.
ID string
// Picker UI — keep both one-line.
Name string
Description string
// Module that owns the template. Surfaced as a chip
// in the picker so users can tell which subsystem a
// template comes from.
Source string
// Optional filter chips in the picker.
Tags []string
// Full IR workflow — only sent over the wire when the
// user clicks "Create from template".
Document *ir.Workflow
}
type TemplateProvider interface {
Templates() []FlowTemplate
}
Templates() is called once per GET /flows/templates request — keep it cheap. The result is deduplicated by ID with first-wins semantics, so a project module can override a framework template by reusing its ID.
Minimal module
package mymodule
import (
"context"
"encoding/json"
_ "embed"
"github.com/redelay/go-flowdsl/flowexec"
"github.com/redelay/go-flowdsl/ir"
"github.com/redelay/go-framework/modules"
"go.uber.org/zap"
)
//go:embed flows/welcome-email.flowdsl.json
var welcomeEmailJSON []byte
type Module struct {
modules.IRBase
logger *zap.Logger
}
func init() {
modules.RegisterFactory("mymodule", func(deps modules.ModuleDeps) (modules.Module, error) {
return &Module{logger: deps.Logger}, nil
})
}
func (m *Module) Startup(_ context.Context) error { return nil }
func (m *Module) Shutdown(_ context.Context) error { return nil }
// Templates implements flowexec.TemplateProvider.
func (m *Module) Templates() []flowexec.FlowTemplate {
var doc ir.Workflow
if err := json.Unmarshal(welcomeEmailJSON, &doc); err != nil {
m.logger.Warn("malformed embedded template", zap.Error(err))
return nil
}
return []flowexec.FlowTemplate{{
ID: "mymodule/welcome-email",
Name: "User signup → welcome email",
Description: "Listens for users.user_created and sends a templated welcome email.",
Source: "mymodule",
Tags: []string{"events", "email", "onboarding"},
Document: &doc,
}}
}
Blank-import the module in your cmd/api/main.go (or cmd/admin-api/main.go) and the template appears in the picker on next boot. Nothing else to wire.
The JSON document
Each template document is an ir.Workflow serialised as JSON:
{
"id": "welcome-email",
"name": "User signup → welcome email",
"description": "Sends a templated welcome email when a user signs up.",
"nodes": [
{
"id": "src",
"name": "On users.user_created",
"kind": "start",
"action_ref": "redelay/event-source",
"config": {
"eventName": "users.user_created",
"groupID": "welcome-email"
}
},
{
"id": "send",
"name": "Send Templated Email",
"kind": "action",
"action_ref": "redelay/email-send-templated"
},
{
"id": "end",
"name": "Sent",
"kind": "end"
}
],
"edges": [
{"id": "e1", "from": "src", "to": "send", "delivery_mode": "durable"},
{"id": "e2", "from": "send", "to": "end", "delivery_mode": "direct"}
],
"meta": {
"positions": {
"src": {"x": 0, "y": 0},
"send": {"x": 360, "y": 0},
"end": {"x": 720, "y": 0}
}
}
}
Top-level fields
| Field | Type | Purpose |
|---|---|---|
id | string | Internal flow id. The backend overrides this when seeding the new flow's first version, so any value works. |
name, description | string | Used as the new flow's name + description if the user doesn't override them. |
nodes[] | array | Canvas nodes. |
edges[] | array | Connections between nodes. |
meta.positions | object | {nodeId: {x, y}} map for canvas layout. Required for sane rendering. Without it every node stacks on the origin. |
meta.packets | object | Optional packet definitions referenced from edges. Skip unless you need explicit typing. |
Node fields
| Field | Type | Notes |
|---|---|---|
id | string | Unique within the flow. |
name | string | Title shown on the canvas. |
kind | string | One of start, end, action, event, gateway, condition, wait, parallel. Maps to the spec node "kind" via irToSpecKind. |
action_ref | string | The node handler to invoke. Must match an id: from a registered flowdsl_nodes entry — open /modules after boot to see the live list. |
config | object | Per-node settings. Keys must match the node's settings_schema.properties — unknown keys are silently dropped. |
Edge fields
| Field | Type | Notes |
|---|---|---|
id, from, to | string | Required. |
delivery_mode | string | direct, ephemeral, checkpoint, durable, or stream. See Choosing a transport. |
condition | string | Used on edges leaving a gateway (e.g. core/if outputs true/false — set condition: "true" and "false" on the two outgoing edges). |
packet | string | Optional $ref to a packet schema. Drop this unless the ref matches one of the connected port schemas exactly — mismatches surface as validation warnings in the canvas. |
Authoring rules
- Every
action_refmust exist at runtime. The picker shows your template even if a referenced node is missing, but the canvas will then render a "broken" node. Only reference nodes from modules your project blank-imports. - Every
configkey must match the node'ssettings_schema. Browse/modules/{action_ref}for the live schema. Unknown keys won't error — they just have no effect, which is worse. - Drop unsafe
packetrefs. The Studio validator compares each edge'spacketref against the port schemas of the connected nodes. If it doesn't match, you get a warning. For starter templates, leavepacketoff; let users add explicit typing as they refine the flow. - Always include
meta.positions. A canvas with everything stacked at(0, 0)is unusable; users will judge the template by first impression. - Keep templates focused. Five tight starters beat one sprawling kitchen-sink flow. The picker groups by
Source, so if your module has more than ~5 templates, consider splitting them into themed groups.
Packets, ports, and edges
Three concepts that are easy to confuse — worth getting straight before you author a template.
Ports vs packets
- A port is a typed input or output declared by a node's manifest (
flowdsl_nodes[].inputs/outputs). Each port carries one schema, e.g.email-senddeclares one input portEmail(schemaEmailSendInput) and two output portsSent(schemaEmailSendResult) andError(schemaRedelayErrorResponse). - A packet is a payload type — either a local
components.packets.<Name>defined in your flow document, or a$refinto an external spec (asyncapi#/components/schemas/...). - An edge has an optional
packet:field. It declares the payload shape that flows through that specific connection.
Inheritance: what happens when you don't set packet
When edge.packet is unset, the Studio shows the edge label in the colour and name of the source port's first output schema — and the runtime carries exactly that shape. There is no extra boxing or coercion. Setting packet is only useful when:
- The source port emits a generic envelope (e.g.
EventMessage) and you want to declare the concrete payload your downstream node assumes — a documentation aid, not a runtime cast. - You insert a transform between two nodes that don't already speak the same shape, and want the canvas to validate the new packet against both ports.
For starter templates: leave packet off. The Studio's edge popover will show the inherited shape, marked INHERITED, with a one-click path to override.
Multi-output nodes
When a node declares more than one output port (the canonical case is Sent / Error), draw one edge per port and add a condition: to each that picks the matching port. The runtime evaluates the conditions against the step's chosen output port + packet payload + input.
{"id": "ok", "from": "send", "to": "audit", "delivery_mode": "durable",
"condition": "output.name == \"Sent\""},
{"id": "fail","from": "send", "to": "alert", "delivery_mode": "durable",
"condition": "output.name == \"Error\""}
The Studio's edge inspector exposes a port picker that writes this exact form for you — no need to remember the syntax.
Edges with no condition (or condition: "true") act as the default fallback — they fire only when no conditional edge matched. That keeps the engine deterministic when an author leaves one branch unconditioned: the conditional edges win when they should, and the default catches everything else.
Edge condition expression language
The same condition: field accepts richer expressions, not just port matches. Use them when routing depends on packet contents — e.g. severity gates, error codes, user tier, or combinations of port + payload.
Variables
| Path | Resolves to |
|---|---|
port | The output port the handler emitted on (string) |
output | Same as port (alias for the port-comparison shorthand) |
output.name | Same as port |
output.<field.path> | A field on the step's output packet |
packet.<field.path> | Alias for output.<field.path> — reads more naturally on payload conditions |
input.<field.path> | A field on the packet that arrived from the previous step |
Missing fields evaluate to null rather than erroring — packet.absent == null is the idiomatic "field is unset" check.
Literals
- Strings —
"double"or'single'quotes - Numbers — int or float, with optional leading
- - Booleans —
true,false - Null —
null(ornil)
Operators (lowest → highest precedence)
| Operator | Meaning |
|---|---|
|| | Logical OR (short-circuiting) |
&& | Logical AND (short-circuiting) |
! | Logical NOT |
== != | Equality (loose: numbers compare by value across int/float, null == null) |
< <= > >= | Numeric comparison (string ↔ number is auto-parsed; type mismatches yield false, never error) |
( … ) | Grouping |
Examples
# Port match — what the Studio writes by default
output.name == "Sent"
# Combined: port + packet field
output.name == "Sent" && packet.recipient.vip == true
# Severity gate
packet.severity == "high" || packet.code >= 500
# Reference inputs as well as outputs
input.userTier == "enterprise" && packet.feature == "beta"
# Negation + nested null check
!(packet.error == null)
Failure handling. A condition that fails to parse or evaluate is treated as false — the engine skips that edge and tries the next candidate (typically the unconditional default). The runtime exposes a runtime.ConditionErrorHook that the executor wires into its sink so authors see the parse error in the run timeline. A bad condition will never abort the whole flow.
What's deliberately not in the language. Function calls (len(), contains(), match()), arithmetic (+, -, *), array iteration. The intent is "router predicates", not "data transformation". For anything more complex, route through a transform or core/if node and let regular handlers do the work.
Lifecycle
- Studio opens the Create modal → switches to the From template tab.
- Studio calls
GET /flows/templates(optionally?q=...for server-side search).- Backend walks
registry.All(), callsTemplates()on everyTemplateProvider, returns id + name + description + source + tags only (no documents).
- Backend walks
- User clicks a template card. Studio calls
GET /flows/templates/{id}.- Backend looks up the matching template, converts the
ir.Workflowto spec form viaspec.FromWorkflow, returns the document.
- Backend looks up the matching template, converts the
- User picks a name, hits Create from template.
- Studio creates the flow, then
POST /flows/{id}/versions?format=specwith the document, thenPOST /flows/{id}/publish.
- Studio creates the flow, then
The whole path is read-mostly — the only writes are the version + publish at step 4, and only after the user explicitly commits.
Where to look
- Reference implementation:
go-modules/flowtemplates— six canonical starters: events→DB, scheduled API polling, DB→LLM→alert, welcome email, failed-login burst, anddebug-playground(an infra-free pricing walkthrough for learning the Flow Debug tools — publish it, hit Run, and drop a tap on a node). All are covered bymodule_test.go, which asserts every embedded template forms a valid graph. - Embedded interface:
github.com/redelay/go-flowdsl/flowexec/template.go. - Picker UI:
flowdsl/studio/src/hosted/components/FlowCreateModal.tsx. - Discovery loop:
collectTemplates()ingo-flowdsl/flowexec/module/module.go. - HTTP endpoints:
GET /flows/templates,GET /flows/templates/{id}(admin-only, both behindauth.RequireActiveUser+admin:accesspermission).
Infrastructure Modules
Generic FlowDSL node packs for Redis, MongoDB, ClickHouse, HTTP, filesystem, Postgres, RabbitMQ, SMS, FTP/SFTP, and the core stdlib primitives — compose any flow without hardcoding infrastructure calls.
Assistant (chat + handoff)
Project-local AI assistant module — FlowDSL-backed chat, persisted conversations with TTL, LLM cost tracking, and a human-handoff path that fans out via the event bus.