Workflows
Make the busywork run itself
A workflow is a graph of steps that fires when something happens in an organization — a record is created, a webhook arrives, or you press run. Wire it visually or describe it as JSON; the engine walks it, atomically.
The model
A workflow has a trigger and a body of steps. The body is stored in one of two shapes, and the runner dispatches on which it finds:
- Graph (v2) — a
{ version, nodes, edges }object. Nodes are steps; edges connect them and carry abranchhandle for conditional routing. This is what the visual builder saves. - Flat (v1) — a plain list of step objects run top to bottom. Each step's output becomes a variable the next can reference. Blueprint manifests and hand-authored workflows often use this terser shape.
Both shapes run through the same step-execution path, so a step behaves identically whichever way you authored it. A graph's nodes and a flat list's entries are the same kind of object — just wired differently.
{
"version": 2,
"nodes": [
{ "id": "trigger", "type": "trigger", "event": "entry.created" },
{
"id": "n1", "type": "branch",
"condition": { "field": "trigger.entry.data.status", "eq": "won" }
},
{
"id": "n2", "type": "send_email",
"to": "{{ trigger.entry.data.owner_email }}",
"subject": "Deal won: {{ trigger.entry.data.name }}",
"body": "Nice work."
}
],
"edges": [
{ "from": "trigger", "to": "n1" },
{ "from": "n1", "to": "n2", "branch": "on_true" }
]
}[
{
"type": "entry.create",
"schema": "tasks",
"data": { "title": "Follow up", "deal_id": "{{ trigger.entry.id }}" }
},
{
"type": "notification.send",
"to": "{{ trigger.entry.data.owner_id }}",
"title": "New task created"
}
]Triggers
Each workflow names one trigger_event. When a matching event is published on the organization's event bus, every enabled workflow whose trigger matches fires. Two families drive workflows automatically:
- Content events —
entry.created,entry.updated,entry.published,entry.deleted, emitted whenever records change in any of your data models. - Inbound integration events —
integration.*, raised when a connected service (see integrations) delivers an inbound webhook.
Triggers can be exact (entry.created) or wildcard (entry.*, or * for every event). A manual trigger never fires from events — it runs only when you invoke it from the workflow page or the run endpoint. You can narrow a trigger further with trigger_conditions: an ABAC condition over the event payload (for example, only fire for one schema, or only when a field equals a value) that is evaluated before the workflow runs.
The picker is populated by GET /admin/v1/workflows/events, which assembles the trigger catalog from core events, custom event definitions, per-schema record verbs, and the wildcards above.
Workflow-emitted events don't loop
Events raised by a running workflow (theemit_event step) are not fanned back into workflow dispatch, so workflows can't trigger each other into an infinite loop. A per-run depth cap guards the rest. Step types
The engine ships 26 built-in step types, registered in the step registry. Each is a small, typed unit with its own config. They group into the same palette categories the visual builder uses:
| Step | Name | Does |
|---|---|---|
| branch | Branch | Two-way split on an ABAC condition (on_true / on_false) |
| switch | Switch | Multi-way split — first matching case, else default |
| for_each | For each | Loop a subgraph (or sub-list) over a list of items |
| wait | Wait | Pause for a number of seconds |
| set_variable | Set variable | Assign a run variable for later steps to read |
| send_email | Send email | Send via a verified org sender, falling back to the connector |
| send_webhook | Send webhook | POST a payload to a URL |
| http_request | HTTP request | Call an external endpoint (SSRF-guarded) |
| integration.call | Call integration | Invoke a connector action (Slack, Stripe, …) |
| entry.create | Create entry | Write a new record into a data model |
| entry.update | Update entry | Patch an existing record |
| entry.delete | Delete entry | Remove a record |
| entry.find | Find entries | Query records to feed later steps |
| notification.send | Send notification | Send an in-app notification |
| emit_event | Emit event | Publish a custom event onto the bus |
| event.broadcast_all | Broadcast to all users | Push a realtime event to every user |
| data.filter | Filter list | Keep list items matching a condition |
| data.map | Map list | Transform each item of a list |
| data.dedupe | Dedupe list | Drop duplicate items from a list |
| csv.export | Export to CSV | Serialise rows to CSV |
| csv.import | Import from CSV | Parse CSV into rows |
| pdf.render | Render HTML to PDF | Produce a PDF from HTML |
| ai.generate | AI generate text | Generate text from a prompt |
| calendar.find_conflict | Find calendar conflicts | Detect overlapping time ranges |
| location.within_radius | Within geo radius? | Test a point against a centre + radius |
| log.write | Log | Write a line to the run trace |
On the count
Some older copy says 8 or 16 step types. The registry is the source of truth: it registers 26.Branches, switches, loops and waits
Control flow lives in the graph, not in code. In a graph, a step's decision picks which edge to follow next:
- Branch evaluates a condition and routes down the
on_trueoron_falseedge. In a flat list (no edges) a false branch acts as a guard — it halts the rest of the chain. - Switch compares a value against ordered cases and routes down the first match's
case_<i>edge, ordefaultif none match. - For each reads a list at
items_pathand runs the subgraph wired to itsiterhandle once per item — with the current item bound to a variable — then continues down thedoneedge. Iterations are capped at 500 so a runaway list can't pin a worker. - Wait pauses the run for a number of seconds before continuing.
Plain steps with multiple outgoing default edges fan out — the executor runs each downstream path in series. Steps pass data forward with templates: a config string can interpolate a {{ … }} placeholder pointing at the event payload or a previous step's output (a step's output is stored under both its node id and its action name). A placeholder that fills a whole field resolves to the real value — number, object or list — not just a string.
The dry-run tracer
Before a workflow touches anything, you can dry-run it against a fixture event. The tracer walks the graph exactly the way the real executor would — evaluating branch conditions, picking switch cases, counting for_each items — but runs no steps and records no run. It returns a structural trace: one verdict per visited node, with the routing decision (a branch's result, a switch's case, a for-each count).
# Dry-run a workflow against a fixture event — no side effects, no run recorded
curl -X POST https://api.polimorf.com/admin/v1/workflows/<workflow_id>/test \
-H 'Authorization: Bearer <access_token>' \
-H 'Content-Type: application/json' \
-d '{"event": "entry.created",
"payload": {"entry": {"data": {"status": "won"}}}}'
# Response: a structural trace with one verdict per visited node
# { "trace": [
# { "id": "trigger", "type": "trigger", "ok": true },
# { "id": "n1", "type": "branch", "ok": true, "result": true },
# { "id": "n2", "type": "send_email", "ok": true }
# ], "context": { ... } }In the visual builder this is the Test action: it prompts for a fixture payload, calls the same endpoint, and lights up the nodes the run would visit — green for visited, red for a node that errored while evaluating — so you can see the path before committing to it.
Build it visually
The visual builder is a node canvas: drag step types from the palette, wire handles into edges, and edit each node's config in the inspector. It snaps to a grid, has one-click tidy layout, full undo/redo, autosave, and live validation that flags a half-wired branch before it can ship. The same canvas exposes Test (dry-run) and Run (real execution) so you can iterate without leaving the page.
Everything the builder produces is the v2 graph shape — so anything you draw, you can also author by hand as JSON, and vice versa. See the workflows overview for the bigger picture.
How a run executes
A run is recorded as a WorkflowRun and executed inside a single nested transaction. Record-writing steps (entry.create, entry.update, entry.delete) share that transaction, so if any step fails the whole run's writes roll back together — you never get half-applied state. The run row itself survives the rollback and is marked failed, with the offending step's error captured in the trace.
Runs are bounded so a misbehaving workflow can't run away: at most 1000 steps per run, a 300-second wall-clock budget shared across every step, and outbound HTTP guarded against requests to internal addresses. A workflow declaring more than the step ceiling is refused outright.
You can also run a workflow on demand, bypassing the event bus. It goes through the same transactional runner, so a manual run is just as atomic as an event-triggered one:
# Execute a workflow now, bypassing the event bus (records a WorkflowRun)
curl -X POST https://api.polimorf.com/admin/v1/workflows/<workflow_id>/run \
-H 'Authorization: Bearer <access_token>' \
-H 'Content-Type: application/json' \
-d '{"payload": {"entry": {"data": {"status": "won"}}}}'
# Response carries the run id, status, and per-step results
# { "run_id": "…", "status": "completed", "results": [ … ] }Where to next
- Integrations — connect Slack, Stripe and Mailgun, then call them from the
integration.callstep or receive inbound events as triggers. - Schema designer — the data models whose record events trigger workflows and whose entries the
entry.*steps write. - Blueprints — ready-made setups that ship working workflows you can adapt.