All docs

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 a branch handle 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.

json · graph (v2)
{
  "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" }
  ]
}
json · flat (v1)
[
  {
    "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 eventsentry.created, entry.updated, entry.published, entry.deleted, emitted whenever records change in any of your data models.
  • Inbound integration eventsintegration.*, 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 (the emit_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:

StepNameDoes
branchBranchTwo-way split on an ABAC condition (on_true / on_false)
switchSwitchMulti-way split — first matching case, else default
for_eachFor eachLoop a subgraph (or sub-list) over a list of items
waitWaitPause for a number of seconds
set_variableSet variableAssign a run variable for later steps to read
send_emailSend emailSend via a verified org sender, falling back to the connector
send_webhookSend webhookPOST a payload to a URL
http_requestHTTP requestCall an external endpoint (SSRF-guarded)
integration.callCall integrationInvoke a connector action (Slack, Stripe, …)
entry.createCreate entryWrite a new record into a data model
entry.updateUpdate entryPatch an existing record
entry.deleteDelete entryRemove a record
entry.findFind entriesQuery records to feed later steps
notification.sendSend notificationSend an in-app notification
emit_eventEmit eventPublish a custom event onto the bus
event.broadcast_allBroadcast to all usersPush a realtime event to every user
data.filterFilter listKeep list items matching a condition
data.mapMap listTransform each item of a list
data.dedupeDedupe listDrop duplicate items from a list
csv.exportExport to CSVSerialise rows to CSV
csv.importImport from CSVParse CSV into rows
pdf.renderRender HTML to PDFProduce a PDF from HTML
ai.generateAI generate textGenerate text from a prompt
calendar.find_conflictFind calendar conflictsDetect overlapping time ranges
location.within_radiusWithin geo radius?Test a point against a centre + radius
log.writeLogWrite 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_true or on_false edge. 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, or default if none match.
  • For each reads a list at items_path and runs the subgraph wired to its iter handle once per item — with the current item bound to a variable — then continues down the done edge. 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).

bash · dry-run
# 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:

bash · run now
# 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.call step 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.