A P1 ticket lands. "Email server down — nobody can send."
Somebody on the team has to log it, set the priority to Critical, assign it to the on-call engineer, post in the #incidents Teams channel, create a follow-up task for the post-mortem, and email the comms lead. Each of those steps is two clicks. Each one is the same set of two clicks every single P1, every single time.
This is the routine work that fills the gaps between the actual technical work. It's never enough on its own to justify a project, but it absorbs hours per week and it’s where mistakes happen — the email that nobody remembered to send, the task that should have been auto-created, the priority that quietly stayed at Normal because the analyst was already typing the next thing.
The Workflows module is the place to spend those hours once and then never again. It’s a visual rules engine sitting above every other module: when this event happens and these conditions hold, run these actions.
Three primitives. One canvas. Every module talks to every other module through it.
The rest of this piece is about why that shape — trigger, conditions, actions — turns out to be enough for most real workflows, how the visual editor stays useful without becoming Visio, and the AI co-author that drafts a workflow from one sentence of plain English.
One: three primitives is enough
Every workflow you build is some combination of the same three nodes. There's no fourth.
When this happens
One of the catalogue of events the engine knows about — ticket created, status changed, priority changed, assigned, form submitted, task completed, change approved.
"When a ticket is created"
and these are true
Field comparisons against the event payload. Equals, in, contains, gt, lt, is_empty — with the operator dropdown filtered to what actually makes sense for the field's type.
"and the priority is Critical or High and the department is Finance"
do this.
Eight handlers today: set status, set priority, assign, add note, send email, create task, create ticket, log message. More to come (Graph API, Teams, Slack).
"then assign to the on-call analyst and create a follow-up task"
The constraint is the productive bit. Because every workflow is the same shape, the editor only needs to understand three node types and the AI co-author only needs to know how to generate three things. The catalogue of triggers, operators, and actions is the entire language — if it's not in there, no workflow can use it; if it’s in there, every workflow can.
The conditions evaluate AND-semantics across the list. "OR" is expressed with multi-select: an in condition on ticket.priority_id with the value [3, 4] reads as "priority is High or Critical" in one line, rather than two conditions you have to join.
Variable substitution travels through every free-text action arg. {{ticket.id}}, {{ticket.subject}}, {{old_priority_id}}, anything reachable by dotted-path against the event payload. So you can write a send-email action with subject Ticket {{ticket.id}} escalated once and have it work on every ticket that fires the workflow.
Two: the canvas knows what each field means
The first time you build a workflow you don't want to know that priority is stored as ticket.priority_id = 4. You want to type "Critical" and have the editor figure it out.
The condition panel inspects the field you pick and adapts the value control to suit. ticket.priority_id opens a dropdown of real priorities pulled from the ticket_priorities table. ticket.department_id opens a list of actual departments. ticket.assigned_analyst_id opens a checkbox list of active analysts so you can pick more than one. Free-text fields like ticket.subject still get a plain input. The operator dropdown narrows to what makes sense too — you can't pick gt on a text field or contains on a numeric id, because both are slow ways to write bugs.
The same lookup awareness extends to actions. The set_ticket_status action's status arg is a dropdown sourced from ticket_statuses at render time, so adding a new status under Tickets → Settings → Statuses immediately makes it picklist-eligible inside Workflows — no separate registration step. Same for priorities, departments, ticket types, analysts, teams, task statuses, task priorities, forms.
One source of truth, two consumers. The condition value picker and the action arg picker call the same lookup helper, so they can never drift out of sync.
Three: describe it in English, get a draft
The blank canvas is harder than it looks. You know roughly what you want the workflow to do, but you have to translate that intent into the right combination of trigger + conditions + actions, with the right field names, in the right order. The AI co-author shortens that path.
The toolbar's AI co-author button opens a modal where you type what you want in plain English — "when a P1 ticket from Finance is created, assign to Sarah and create a task for the post-mortem". The backend sends the request to Claude (or OpenAI — provider is configurable per install) with a system prompt that contains the live engine catalogues: the full list of triggers, the operators valid for each field type, every action with its arg spec, and the first 8 id→label pairs for every lookup table.
The model returns a JSON proposal which the server then validates against the same catalogues. Unknown triggers fall back to the first known one. Unknown operators get dropped with a warning. Action args are coerced to the right shape for the type (array for in / not_in, string otherwise, integer for lookups).
Two consequences fall out of that design. First: the AI cannot propose something the engine doesn't understand — the catalogue is the universe of legal moves, and the validator enforces it. Second: when the catalogue grows — new trigger, new action, new operator — the AI immediately knows about it on the next call because the system prompt is built from the live engine, not a hard-coded list.
The modal shows the AI's plain-English explanation of what it built, a colour-coded preview of the proposed workflow (trigger pill, condition diamonds, action rectangles), and any warnings about things it dropped. Apply drops the proposal onto the canvas. Discard lets you try again with different wording.
Four: it lives above the modules
An obvious alternative design is to bury automation inside each module — Tickets gets its own little rules engine, Tasks gets one, Forms gets one. Most ITSM tools that don’t have a strong workflow story actually have multiple half-finished ones, one per module, that can't talk to each other.
The Workflows module is deliberately a separate module sitting above Tickets / Tasks / Forms / Changes, not inside any of them. The reason is structural: the workflows that earn their keep are inherently cross-module.
- New starter form submitted → create three tickets across IT / HR / Facilities, plus four onboarding tasks.
- Critical incident ticket created → auto-assign, set the SLA timer, post in the comms channel, schedule a post-mortem task.
- Change approved → create the implementation ticket, link the affected CMDB objects, notify the change manager.
A per-module rules engine can't express any of these — each one needs to write into a different module from the one that fired the trigger. By sitting above the modules, the Workflows engine can: it owns the rule registry and the execution audit; the host modules each emit one line on their save flow (WorkflowEngine::dispatch('ticket.created', $payload)) and that’s the entirety of their integration responsibility.
Same shape for every host. Every new trigger added to the catalogue is one dispatch call in the relevant save endpoint, no new editor code, no new validation code, no schema migration.
Five: the safety story
Automation that breaks production is worse than no automation. Two design choices keep the engine boring in the right ways.
First, engine failures are isolated. WorkflowEngine::dispatch() catches every exception internally — a failing condition or action is logged to the workflow_executions audit row as failed or skipped, the action loop carries on to the next action, and the host module's save response goes back to the client untouched. On top of that, every call site in the host wraps the dispatch in its own try/catch as a belt-and-braces second layer. The ticket save can never break because the workflow engine had a bad day.
Second, execution is observable. Every workflow has a recent-runs sidebar showing the last 20 executions with status, trigger payload snapshot, full step-by-step log, and error messages. The first time a workflow misbehaves you can see exactly which condition evaluated to false unexpectedly, which action threw, and what the payload looked like — without grepping PHP error logs.
Third, test fire without firing real events. The editor's Test fire button builds a synthetic payload from the workflow's own conditions and runs the engine end-to-end. Equality conditions get values that trivially pass; inequality conditions get sentinels that don't match; contains gets the value embedded in a longer string. The conditions all pass against the synthetic payload, so the action path actually executes and you get a real success / failed status with the full step log — before the workflow ever touches a real ticket.
The visual style follows the data: trigger nodes are amber pills pinned at the top (one per workflow, always there), condition nodes are orange diamonds (because conditions branch in logic), action nodes are blue rounded rectangles (because actions execute linearly). Execution order = top-to-bottom sorted by y at save time. To reorder, you drag.
What this earns the right to do
The point of any rules engine is to let the team spend their time on the part that requires a human and let the machine do the part that doesn't. The honest test isn't "could I automate this?" — almost everything is automatable in principle. The test is "is the cost of building, testing and maintaining the automation cheaper than just doing the thing by hand?"
The Workflows module is trying to push that cost as low as possible: drag three nodes onto a canvas, click Save, the workflow is live. Test fire it before turning it on for real. Use the AI co-author to skip the first draft. If you change your mind, the visual diagram is the source of truth — no buried YAML to edit, no JSON to copy-paste between environments.
Stage 1 (the engine, the editor, the catalogue), Stage 2 (the visual canvas), Stage 3 (the AI co-author), Stage 4 (eight real action handlers + variable substitution), and the Tickets portion of Stage 5 (real triggers wired in) all ship today. Forms / Tasks / Changes trigger wiring is next, then external orchestration via Graph API (reusing the existing mailbox OAuth scaffolding for Directory.ReadWrite.All / Group.ReadWrite.All), then operational polish (dry-run mode, retry semantics, infinite-loop protection, starter recipes, versioning).
None of that is finished. But the bit that’s here works end-to-end, and the bit that’s here is the bit that gets used the most. The rest is iteration.