Reference

Flow Templates

Contributing starter flows from a Go module — the TemplateProvider interface, JSON document shape, and lifecycle.

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

go
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

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

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

FieldTypePurpose
idstringInternal flow id. The backend overrides this when seeding the new flow's first version, so any value works.
name, descriptionstringUsed as the new flow's name + description if the user doesn't override them.
nodes[]arrayCanvas nodes.
edges[]arrayConnections between nodes.
meta.positionsobject{nodeId: {x, y}} map for canvas layout. Required for sane rendering. Without it every node stacks on the origin.
meta.packetsobjectOptional packet definitions referenced from edges. Skip unless you need explicit typing.

Node fields

FieldTypeNotes
idstringUnique within the flow.
namestringTitle shown on the canvas.
kindstringOne of start, end, action, event, gateway, condition, wait, parallel. Maps to the spec node "kind" via irToSpecKind.
action_refstringThe node handler to invoke. Must match an id: from a registered flowdsl_nodes entry — open /modules after boot to see the live list.
configobjectPer-node settings. Keys must match the node's settings_schema.properties — unknown keys are silently dropped.

Edge fields

FieldTypeNotes
id, from, tostringRequired.
delivery_modestringdirect, ephemeral, checkpoint, durable, or stream. See Choosing a transport.
conditionstringUsed on edges leaving a gateway (e.g. core/if outputs true/false — set condition: "true" and "false" on the two outgoing edges).
packetstringOptional $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_ref must 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 config key must match the node's settings_schema. Browse /modules/{action_ref} for the live schema. Unknown keys won't error — they just have no effect, which is worse.
  • Drop unsafe packet refs. The Studio validator compares each edge's packet ref against the port schemas of the connected nodes. If it doesn't match, you get a warning. For starter templates, leave packet off; 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-send declares one input port Email (schema EmailSendInput) and two output ports Sent (schema EmailSendResult) and Error (schema RedelayErrorResponse).
  • A packet is a payload type — either a local components.packets.<Name> defined in your flow document, or a $ref into 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:

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

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

PathResolves to
portThe output port the handler emitted on (string)
outputSame as port (alias for the port-comparison shorthand)
output.nameSame 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 (or nil)

Operators (lowest → highest precedence)

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

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

  1. Studio opens the Create modal → switches to the From template tab.
  2. Studio calls GET /flows/templates (optionally ?q=... for server-side search).
    • Backend walks registry.All(), calls Templates() on every TemplateProvider, returns id + name + description + source + tags only (no documents).
  3. User clicks a template card. Studio calls GET /flows/templates/{id}.
    • Backend looks up the matching template, converts the ir.Workflow to spec form via spec.FromWorkflow, returns the document.
  4. User picks a name, hits Create from template.
    • Studio creates the flow, then POST /flows/{id}/versions?format=spec with the document, then POST /flows/{id}/publish.

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, and debug-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 by module_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() in go-flowdsl/flowexec/module/module.go.
  • HTTP endpoints: GET /flows/templates, GET /flows/templates/{id} (admin-only, both behind auth.RequireActiveUser + admin:access permission).