NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

A workflow is one JSON document. The package publishes it as JSON Schema (a draft 2020-12 subset) through Workflows::schema()->graph(); the template envelope through Workflows::schema()->template(); and the shared $defs (value, field, party, parties, duration, condition) through Workflows::schema()->definitions() — the graph schema adds retry and escalation. Node configs are typed per step type: see Workflows::catalogue()->export().

Workflows::schema()->graph();         // the roundly.workflows.graph/v1 JSON Schema
Workflows::schema()->template();      // the roundly.workflows.template/v1 envelope
Workflows::schema()->definitions();   // the shared $defs: value, field, party, parties, duration, condition
Workflows::catalogue()->export();     // every node type's config schema, outcomes and availability

The document

{
  "schema": "roundly.workflows.graph/v1",          // required; anything else → invalid_schema_version
  "settings": {                                     // optional; each key has a default
    "actor": "system" | "on_behalf_of",             // default "system"
    "timezone": "Europe/Bratislava",                // IANA; default workflows.timezone → app.timezone
    "calendar": "weekdays",                         // default business calendar
    "limits": { "max_steps": 200 },                 // may only LOWER the configured limit
    "retry": { "max_attempts": 3, "backoff": [30, 120, 600] }
  },
  "nodes": [ {
      "id": "approve_cfo",                          // ^[a-z][a-z0-9_]{0,63}$, unique
      "type": "approval",                           // a catalogue key
      "config": { },                                // validated against the type's config schema
      "retry": { },                                 // optional per-node override
      "escalations": [ ],                           // only on types that support them
      "ui": { "x": 120, "y": 80, "label": "CFO sign-off" }   // free-form; ignored by the engine
  } ],
  "edges": [ {
      "from": "approve_cfo", "to": "notify_rejected",
      "outcome": "rejected"                         // required unless the source's only outcome is `next`
  } ]
}

Rules

  • Exactly one trigger node, at least one end node.
  • Size caps: nodes ≤ graph.max_nodes (500), edges ≤ graph.max_edges (2000), encoded graph ≤ graph.max_bytes (512 KiB) → graph_too_large.
  • An edge is identified by (from, outcome). Two edges with the same pair → duplicate_outcome. One token moves through the graph: there are no parallel branches.
  • An edge without outcome stands for next and nothing else, so it is allowed only from a node whose one outcome is next (set_field, delay, an action step, …). A node with any other outcome — even a single one, like start_workflow without wait (started), wait (received), webhook (queued) — names it on every edge, else outcome_label_required at publish.
  • A cycle must pass through a breaker: a loop node’s continue edge, or a delay that waits a positive for. A loop’s exhausted edge and a zero-length, until or until_field delay don’t count → cycle_without_breaker.
  • ui is kept verbatim and never interpreted (a diff reports ui-only changes as moved).
  • Saving a draft checks structure only, so a builder can store a half-finished graph. Publish runs every check.

Value references

Wherever a schema allows #/$defs/value, a value is a literal, {"ref": "<path>"}, or {"relative": <duration>} — the run’s “now” plus (or, with a negative amount, minus) a duration, in the version timezone:

"value": "50000"                                          // a literal
"value": { "ref": "subject.amount" }                       // a registered field of the subject
"value": { "ref": "steps.approve.output.request_id" }      // the output of an ancestor step
"value": { "relative": "P7D" }                             // now + 7 days, in the version timezone
"value": { "relative": { "amount": -3, "unit": "days" } }  // now − 3 days
PathReads
subject.<field>A registered field of the subject (never an unregistered attribute).
owner.<field>A registered field of the owner type.
input.<name>A declared manual-trigger input.
trigger.<key>The trigger payload (event payload, webhook body).
steps.<node_id>.output.<key>The output of an ancestor step.
nowThe current instant.
payload.<path>The webhook body — only in a webhook trigger’s subject.value (the subject lookup runs before the instance exists).

Durations

An ISO 8601 string (calendar time in the version timezone) or an object with amount and unit — minutes, hours, days, weeks, months, business_minutes, business_hours, business_days. calendar overrides settings.calendar:

"P2D"                                                       // ISO 8601: calendar time in the version timezone
"PT4H"
{ "amount": 2, "unit": "business_days", "calendar": "opening_hours:office" }

Party references

{"kind": "user", "id": 12}
{"kind": "field", "field": "subject.requester"}
{"kind": "role", "role": "legal"}
{"kind": "triggered_by"}

Versioning of the schema

The schema string is the version. A future incompatible change gets a new string; roundly.workflows.graph/v1 documents keep validating and running.

The graph JSON Schema

Generated by Workflows::schema()->graph() — limits reflect the default config:

{
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://roundly-consulting.com/schemas/roundly.workflows.graph/v1.json",
    "title": "Workflow graph",
    "type": "object",
    "required": [
        "schema",
        "nodes",
        "edges"
    ],
    "properties": {
        "schema": {
            "const": "roundly.workflows.graph/v1"
        },
        "settings": {
            "type": "object",
            "properties": {
                "actor": {
                    "enum": [
                        "system",
                        "on_behalf_of"
                    ],
                    "default": "system"
                },
                "timezone": {
                    "type": "string",
                    "description": "IANA timezone; default workflows.timezone, else app.timezone."
                },
                "calendar": {
                    "type": "string",
                    "description": "Default business calendar (weekdays, opening_hours:<name> or a host calendar)."
                },
                "limits": {
                    "type": "object",
                    "properties": {
                        "max_steps": {
                            "type": "integer",
                            "minimum": 2,
                            "maximum": 200
                        }
                    },
                    "additionalProperties": false
                },
                "retry": {
                    "$ref": "#/$defs/retry"
                }
            },
            "additionalProperties": false
        },
        "nodes": {
            "type": "array",
            "maxItems": 500,
            "items": {
                "type": "object",
                "required": [
                    "id",
                    "type"
                ],
                "properties": {
                    "id": {
                        "type": "string",
                        "pattern": "^[a-z][a-z0-9_]{0,63}$"
                    },
                    "type": {
                        "type": "string",
                        "description": "A step catalogue key."
                    },
                    "config": {
                        "type": "object",
                        "description": "Validated against the type's config schema."
                    },
                    "retry": {
                        "$ref": "#/$defs/retry"
                    },
                    "escalations": {
                        "type": "array",
                        "maxItems": 5,
                        "items": {
                            "$ref": "#/$defs/escalation"
                        }
                    },
                    "ui": {
                        "type": "object",
                        "description": "Free-form (position, label); ignored by the engine."
                    }
                },
                "additionalProperties": false
            }
        },
        "edges": {
            "type": "array",
            "maxItems": 2000,
            "items": {
                "type": "object",
                "required": [
                    "from",
                    "to"
                ],
                "properties": {
                    "from": {
                        "type": "string"
                    },
                    "to": {
                        "type": "string"
                    },
                    "outcome": {
                        "type": "string",
                        "description": "Required unless the source type's only outcome is next."
                    }
                },
                "additionalProperties": false
            }
        }
    },
    "additionalProperties": false,
    "$defs": {
        "value": {
            "description": "A literal, {\"ref\": \"<path>\"} or {\"relative\": <duration>}."
        },
        "field": {
            "type": "string",
            "format": "field-path",
            "maxLength": 200
        },
        "party": {
            "type": "object",
            "required": [
                "kind"
            ],
            "properties": {
                "kind": {
                    "enum": [
                        "user",
                        "field",
                        "role",
                        "triggered_by"
                    ]
                },
                "id": {
                    "type": [
                        "integer",
                        "string"
                    ],
                    "x-workflows-owner-specific": true
                },
                "field": {
                    "$ref": "#/$defs/field"
                },
                "role": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 100
                }
            },
            "additionalProperties": false
        },
        "parties": {
            "type": "array",
            "items": {
                "$ref": "#/$defs/party"
            },
            "minItems": 1,
            "maxItems": 50
        },
        "duration": {
            "anyOf": [
                {
                    "type": "string",
                    "format": "duration"
                },
                {
                    "type": "object",
                    "required": [
                        "amount",
                        "unit"
                    ],
                    "properties": {
                        "amount": {
                            "type": "integer",
                            "minimum": -100000,
                            "maximum": 100000
                        },
                        "unit": {
                            "enum": [
                                "minutes",
                                "hours",
                                "days",
                                "weeks",
                                "months",
                                "business_minutes",
                                "business_hours",
                                "business_days"
                            ]
                        },
                        "calendar": {
                            "type": "string",
                            "minLength": 1,
                            "maxLength": 100
                        }
                    },
                    "additionalProperties": false
                }
            ]
        },
        "condition": {
            "type": "object",
            "description": "A declarative condition: {\"all\"|\"any\": [...]}, {\"not\": {...}} or {\"field\", \"op\", \"value\"}."
        },
        "retry": {
            "type": "object",
            "properties": {
                "max_attempts": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 10
                },
                "backoff": {
                    "type": "array",
                    "items": {
                        "type": "integer",
                        "minimum": 0
                    }
                }
            },
            "additionalProperties": false
        },
        "escalation": {
            "type": "object",
            "required": [
                "after",
                "actions"
            ],
            "properties": {
                "after": {
                    "$ref": "#/$defs/duration"
                },
                "actions": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 5,
                    "items": {
                        "type": "object",
                        "required": [
                            "type"
                        ],
                        "properties": {
                            "type": {
                                "enum": [
                                    "notify",
                                    "reassign",
                                    "start_workflow"
                                ]
                            }
                        }
                    }
                }
            },
            "additionalProperties": false
        }
    }
}

The template JSON Schema

Generated by Workflows::schema()->template() — the envelope of an exported template and of a definition file:

{
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://roundly-consulting.com/schemas/roundly.workflows.template/v1.json",
    "title": "Workflow template",
    "type": "object",
    "required": [
        "schema",
        "definition",
        "graph"
    ],
    "properties": {
        "schema": {
            "const": "roundly.workflows.template/v1"
        },
        "definition": {
            "type": "object",
            "required": [
                "key",
                "name"
            ],
            "properties": {
                "key": {
                    "type": "string",
                    "pattern": "^[a-z0-9][a-z0-9._-]{0,99}$"
                },
                "name": {
                    "type": "string"
                },
                "subject_type": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "description": {
                    "type": [
                        "string",
                        "null"
                    ]
                }
            }
        },
        "graph": {
            "$ref": "roundly.workflows.graph/v1"
        },
        "review": {
            "type": "array"
        },
        "exported_at": {
            "type": "string",
            "format": "date-time"
        },
        "source": {
            "type": "object",
            "properties": {
                "checksum": {
                    "type": "string"
                }
            }
        }
    }
}

Shared definitions

Generated by Workflows::schema()->definitions() — the $defs every step config schema may reference:

{
    "value": {
        "description": "A literal, {\"ref\": \"<path>\"} or {\"relative\": <duration>}."
    },
    "field": {
        "type": "string",
        "format": "field-path",
        "maxLength": 200
    },
    "party": {
        "type": "object",
        "required": [
            "kind"
        ],
        "properties": {
            "kind": {
                "enum": [
                    "user",
                    "field",
                    "role",
                    "triggered_by"
                ]
            },
            "id": {
                "type": [
                    "integer",
                    "string"
                ],
                "x-workflows-owner-specific": true
            },
            "field": {
                "$ref": "#/$defs/field"
            },
            "role": {
                "type": "string",
                "minLength": 1,
                "maxLength": 100
            }
        },
        "additionalProperties": false
    },
    "parties": {
        "type": "array",
        "items": {
            "$ref": "#/$defs/party"
        },
        "minItems": 1,
        "maxItems": 50
    },
    "duration": {
        "anyOf": [
            {
                "type": "string",
                "format": "duration"
            },
            {
                "type": "object",
                "required": [
                    "amount",
                    "unit"
                ],
                "properties": {
                    "amount": {
                        "type": "integer",
                        "minimum": -100000,
                        "maximum": 100000
                    },
                    "unit": {
                        "enum": [
                            "minutes",
                            "hours",
                            "days",
                            "weeks",
                            "months",
                            "business_minutes",
                            "business_hours",
                            "business_days"
                        ]
                    },
                    "calendar": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 100
                    }
                },
                "additionalProperties": false
            }
        ]
    },
    "condition": {
        "type": "object",
        "description": "A declarative condition: {\"all\"|\"any\": [...]}, {\"not\": {...}} or {\"field\", \"op\", \"value\"}."
    }
}

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.