Workflows - edmozley/freeitsm GitHub Wiki
Folder: workflow/
Theme colour: Amber #f59e0b
DB tables: workflows, workflow_executions, webhook_deliveries
Cross-module automation engine. Other modules fire events when things happen ("a ticket was just created", "a priority just changed"); the Workflows module listens for those events, evaluates user-defined conditions against the event's payload, and runs an ordered list of actions when the conditions pass. The strategic answer to "why pick FreeITSM over Halo / ServiceNow" on the automation axis.
A workflow has three parts: a single trigger (the event), zero or more conditions (filters, AND semantics), and one or more actions (what to do).
The most common question, and the one it's easiest to get a wrong mental model of. There are three different cases, and they behave differently.
Making a ticket P1 fires the workflow immediately. Nothing polls. Nothing checks on a timer.
When a ticket is saved, the code that writes it calls WorkflowEngine::dispatch('ticket.priority_changed', โฆ) in the same request, synchronously. The engine finds the active workflows for that event, evaluates their conditions, and runs their actions โ all before the browser gets its response back. It's a phone call, not a letter.
That's why every trigger in the catalogue hangs off a write path: something has to happen for an event to exist. No scheduled task is involved, and none is needed.
Here's the part that catches people.
The send_webhook action does not POST to Slack or Discord. It queues the message into webhook_deliveries and returns instantly. A background worker โ cron/webhook_deliveries.php โ does the actual sending, with retries and backoff.
That's deliberate and it's the right design: if Discord is down or slow, your ticket still saves instantly. Nobody should wait on someone else's outage to file a ticket.
But it means the worker has to be scheduled, or nothing ever leaves the building. A workflow will fire perfectly, queue its message, and the message will sit there forever.
The trap. The editor's Send test button is synchronous โ it bypasses the queue deliberately, so you get an immediate answer. So Send test can work perfectly while every real webhook silently piles up unsent. If your test posts to Discord but your live P1 doesn't, this is why.
System โ Webhooks shows a red banner when the worker isn't running, with the exact command to schedule. Set it to run every minute. Setup:
docs/webhook-cron-setup.md.
"The SLA is about to breach." "This contract expires in 30 days."
These are not events. Nothing happened โ time passed. There's no write to hang a dispatch() off, so a scheduled job has to go and find them.
Four triggers work this way: sla.warning, sla.breached, contract.expiring, asset.warranty_expiring.
โ ๏ธ If the crons aren't scheduled, these triggers never fire. The workflow sits there, active, doing nothing, and nothing tells you why. See Time-based triggers below.
| Scheduled task needed? | |
|---|---|
| Something happened (ticket created, priority changed, form submitted) | No. Instant, event-driven, in-process. |
| A webhook needs sending | Yes โ the delivery worker, every minute. |
| Time passed (SLA breach, contract expiry) |
Yes โ the SLA cron and/or cron/workflow_scheduled.php. |
Seven triggers โ sla.warning, sla.breached, contract.expiring, asset.warranty_expiring, project.stage_due, project.milestone_due and project.milestone_missed โ don't hang off a write path. Nothing happened; time passed. A scheduled job has to go looking for them, and once it does, the hard part isn't finding a breached SLA โ it's not telling you about it 300 times, because a time-based condition stays true.
A fire-once ledger (workflow_scheduled_emissions, unique key + INSERT IGNORE) makes the database the arbiter, and a fingerprint of the state it fired against lets a situation re-arm when the thing underneath it changes โ so raising a ticket's priority, which shrinks its SLA target, is allowed to escalate again against the new deadline.
โ ๏ธ These seven need crons scheduled, or they never fire at all โ the workflow sits there, active, doing nothing, and nothing tells you why.
๐ Full write-up: Time-Based Triggers โ the triggers and their crons, the ledger, the fingerprint, the four starter recipes, cron setup, and how to test the whole thing by hand without waiting for a scheduler.
| Part | What it is |
|---|---|
| Trigger | One of the catalogued events, e.g. ticket.created, problem.status_changed, change.approved, asset.assigned, cmdb.object.created, knowledge.published โ see the full event catalogue. Exactly one per workflow. Adding a new trigger = one entry in WorkflowEngine::availableTriggers() + one dispatch() call from the host module's service (the single write path shared by the UI and the API, so the event fires no matter how the change was made). |
| Conditions | Each condition is {field, op, value}. The engine reads field from the dispatch payload via dot-notation (ticket.priority_id). All conditions must match (AND); if empty the workflow always fires. |
| Actions | Each action is {type, args}. Execute in order; if one fails the chain stops and the rest are marked unrun. The engine's per-step try/catch logs every failure to the execution row's step_log without bubbling the exception out to the host module. |
A few catalogue details worth knowing beyond the event catalogue itself: UI settings endpoints emit their CRUD events via a best-effort wf_emit() helper, while shared services call the engine directly; task.completed fires only on the transition into a closed status (not on every save of an already-closed task); software.application_discovered fires from all three agent-ingest paths when a new application first appears; and form.submitted's payload includes a submission.fields map of every answer keyed by its label, plus the first email-type answer as submission.email.
Catalogue requests add three more form-shaped triggers: catalogue_request.submitted (a gated form was submitted and is waiting on its approver โ a gated submission fires this instead of form.submitted, so an auto-raise rule can't jump the approval gate), catalogue_request.approved and catalogue_request.rejected. All three carry form.id, so a rule narrows to one catalogue item rather than every form on the install, and all three carry the same submission.fields map โ the four form-shaped triggers share one SUBMISSION_FIELD_TRIGGERS const precisely so that {{submission.fields.Start date}} cannot mean one thing at submission and nothing at approval. The decision events additionally carry request.comment and, on approval, request.ticket_id / request.ticket_number.
โ ๏ธ A broken lookup is indistinguishable from a missing one.form.idhad an entry inFIELD_LOOKUP_TABLESall along, reading a column callednameโ the forms table hastitle. The query threw,availableValuesForField()caught it and returnednull, and the editor drew an empty tick-list. So "match on this form" was offered and could never be filled in, and the only rule anyone could save ran for every form on the install. Worth a periodic sweep: exercise every entry in that map against the database, because the catch means nothing else ever will. All 29 pass as of #95.
โ ๏ธ The dropdown lists leaves only. Editing a form forks it into a new row and the frozen versions keep their titles, so listing every row offers the same name several times with no way to tell which is current.โ ๏ธ It follows that a condition pinned toform.idstops matching once that form is versioned โ the submission carries the new leaf's id. That trap is one of the reasons a form's own action list belongs on the form (see the Catalogue Request Approvals developer guide), where versioning carries it forward instead.
โ ๏ธ These three were dispatched from the day approvals shipped but never catalogued, so for their whole life they matched zero workflows and the approver was never told anything was waiting.dispatch()selects ontrigger_eventand the editor can only offer whatavailableTriggers()lists โ so adispatch()call without a catalogue entry is an event that can never be heard, and nothing anywhere reports the mismatch. Worth checking whenever a new event is added. See the Catalogue Request Approvals developer guide.
Conditions and actions are stored as JSON-in-TEXT columns on the workflows table โ so the schema doesn't need a migration every time the engine grows a new operator or action kind.
Process Mapper-style canvas with a dot-grid background, 20px snap-to-grid, and auto-routed SVG connectors. Three node shapes:
- Trigger node โ amber pill, pinned to top-centre. One per workflow; can't be deleted.
- Condition node โ orange diamond (clip-path).
- Action node โ blue rounded rectangle.
Position IS the order. Execution flow at save time is trigger โ conditions (sorted top-to-bottom by y) โ actions (sorted top-to-bottom by y). Drag a condition above another to swap them. No branching in v1 โ purely linear visual.
The toolbar has Back / Add condition / Add action / Status pip (idle / unsaved / saving / saved) / Test fire / Save / AI co-author.
The detail panel slides in from the right with four bodies swapped by selection:
| Body | Shown when |
|---|---|
| Workflow | Nothing selected (click empty canvas) โ name / description / active flag / recent runs |
| Trigger | Trigger node clicked โ event dropdown sourced from WorkflowEngine::availableTriggers()
|
| Condition | Condition node clicked โ field / operator / value |
| Action | Action node clicked โ type dropdown + per-arg form |
Right-click or Delete removes the selected node (except the trigger). Same mouseup-no-drag pattern Process Mapper landed in #335 so a drag never flashes the detail panel in.
The condition value control adapts to the chosen field type โ driven by two const maps on WorkflowEngine:
-
FIELD_LOOKUP_TABLESโ normalised id fields (ticket.priority_id,ticket.status_id, etc.) get a checkbox list of real values from the joined table. Multi-tick gives OR semantics โ "priority is Critical OR High" is one condition withop: in, value: ["4", "3"]rather than two AND-ed conditions which doesn't actually express OR. -
FIELD_TYPESโ drives operator filtering. Numeric fields offerequals/not_equals/in/not_in/gt/lt/is_empty/is_not_empty(nocontainsโ substring search on a number is meaningless). Text fields offer the same minusgt/lt(lexicographic string compare is a footgun) pluscontains/not_contains. Lookup fields collapse tois/is not/is empty/is not emptyโ the multi-select handles the rest.
The editor's field dropdown is populated from WorkflowEngine::availableFields($trigger) โ every ticket.* trigger exposes the full ticket field set (id / subject / priority_id / status_id / department_id / type_id / assigned_analyst_id / owner_id / origin_id / created_by / requester_email) plus event-specific extras (old_status_id / new_status_id / old_priority_id / new_priority_id / analyst_id / team_id).
Eight handlers ship today. Each action's args spec drives the editor UI dynamically โ the user gets a labelled form with the right widget per arg (text input / textarea / number / checkbox / lookup <select>), not a JSON textarea.
| Type | What it does | Key args |
|---|---|---|
log_message |
Writes a message into the workflow's execution log. Placeholder while scaffolding rules. | message |
set_ticket_status |
Changes a ticket's status. Auto-stamps / clears closed_datetime when the new status's is_closed flag flips. |
ticket_id, status_id
|
set_ticket_priority |
Sets a ticket's priority. |
ticket_id, priority_id
|
assign_ticket |
Sets a ticket's owner/assignee. Updates both assigned_analyst_id and owner_id to mirror assign_ticket.php's explicit-analyst branch. |
ticket_id, analyst_id
|
add_ticket_note |
Appends a Workflow Note row to ticket_audit with analyst_id = NULL so the UI can render workflow-generated notes distinctly. |
ticket_id, note
|
send_email |
Sends an email using the ticket's mailbox. Reuses every helper in includes/template_email.php โ mailbox lookup, token refresh, Graph / Gmail dispatch, templateSaveSentEmail, the [SDREF:XXX-NNN-NNNNN] reply-marker so replies thread back. Defaults the recipient to the requester. |
ticket_id, to, subject, body
|
create_task |
Inserts into tasks mirroring api/tasks/save.php โ is_default fallback for status/priority, MAX(board_position) + 1 calc, auto-links the source ticket. |
title, description, status_id, priority_id, assignee_id, ticket_id
|
create_ticket |
Full transactional mirror of api/tickets/create_ticket.php โ upserts the user row by email, generates a unique XXX-NNN-NNNNN number, inserts the ticket, creates an initial Manual-direction email entry so the new ticket appears in the inbox, writes an audit row. |
subject, body, priority_id, department_id, type_id, assigned_analyst_id, from_email, from_name
|
create_project (3.3.0)
|
A project proposal through projectCreateFromAction() (includes/projects/intake.php) - status proposed, waiting for approval when Projects โ Settings says so; from a form, the requester's company, name and email are recorded and blank boxes are filled from the submission. Returns project_id, code, name, approval. |
name, summary, business_case, estimated_cost, estimated_benefit, target_end_date, methodology, priority, owner_analyst_id
|
send_webhook |
Outbound webhook โ POSTs a JSON payload to an external URL, the universal way to push events into Slack, Teams, Discord, PagerDuty, Zapier/Make, n8n or any endpoint that accepts an incoming webhook. preset = Slack/Teams/Discord (wraps the templated message in that platform's JSON) or Custom (sends the raw templated body, which must be valid JSON). An optional secret HMAC-SHA256-signs the body as X-FreeITSM-Signature: sha256=โฆ for authenticity. Delivery is asynchronous โ the action enqueues to webhook_deliveries and returns instantly; a background worker sends it with retries + backoff and dead-lettering. The signing secret is computed at enqueue time and only the header stored (secret never persisted). Track every send under Workflows โ Webhook deliveries (status, attempts, response, Replay). |
preset, url, message, body, secret
|
Action lookups (status / priority / analyst / department / ticket_type / task_status / task_priority) reuse FIELD_LOOKUP_TABLES via the ACTION_ARG_LOOKUPS map and availableActionLookup($key) so action dropdowns and condition value dropdowns share one source of truth.
A webhook is FreeITSM making other systems do things: when a rule fires, it POSTs to a URL you provide. FreeITSM stays deliberately ignorant of the target โ the receiving system publishes an "incoming webhook URL," you paste it in, and (for anything beyond the presets) you shape the payload to match what that system expects. This is why one action reaches thousands of tools without a per-integration build.
Slack / Teams / Discord (preset). In the target app, create an Incoming Webhook and copy its URL. In the workflow, add Send a webhook, set Format to the platform, paste the URL, and write the Message (templated, e.g. P1 raised: {{ticket.subject}} (#{{ticket.id}})). FreeITSM wraps it in the platform's shape โ Slack {"text": โฆ}, Discord {"content": โฆ}, Teams a MessageCard.
Anything else (Custom). Set Format to Custom (raw JSON body) and write the exact JSON the target wants, with {{variables}} filled in. For maximum reach, point it at a Zapier / Make / n8n webhook and let that fan out to any app.
Full-record loading under the hood. The full-object enrichment is a registry of loaders keyed by payload object โ WorkflowEngine::fullObjectLoaders() โ one per resource with a clean API GET /{id}. Each loader runs the resource's own raw SELECT + serialiser rather than the apiLoadX() helpers, because those apiError()/exit on a missing row and would abort the host module's request. Loading is best-effort as well as lazy: if a load fails, .full is simply omitted rather than failing the run.
Verifying authenticity (signing secret). Set a secret and FreeITSM adds X-FreeITSM-Signature: sha256=<hex>, where the hex is HMAC-SHA256(raw_body, secret). The receiver recomputes the HMAC over the exact bytes received and compares โ a match proves the call came from your install and wasn't tampered with. (PHP receiver: hash_equals('sha256='.hash_hmac('sha256', file_get_contents('php://input'), $secret), $_SERVER['HTTP_X_FREEITSM_SIGNATURE']).)
Note
Delivery is asynchronous (shipped): the action enqueues to webhook_deliveries and returns instantly; the cron worker cron/webhook_deliveries.php sends with retries + exponential backoff (1mโ5mโ15mโ1hโ6h) and dead-letters after max_attempts. Schedule it every minute (docs/webhook-cron-setup.md); latency โ the cron interval. Workflows โ Webhook deliveries logs every send with a Replay button. Still ahead: a wider event catalogue so webhooks fire from across the whole product (see What's still ahead).
A blank canvas is a bad first experience. New from template on the Workflows list opens a gallery of 24 ready-made recipes for the jobs most service desks automate first, across 13 categories โ filterable by category and searchable (the search reaches the description, trigger and actions too, so expiry, slack or wipe find the right card even when the name doesn't contain the word).
Tickets and incidents: auto-acknowledge a new ticket, thank the requester when it closes, P1 incident response, alert when a ticket is escalated to P1 (the case a create-time rule misses), broadcast a status-page incident, sound the all-clear when it's resolved (teams remember the outage and never hear it ended), log every ticket that's binned. Onboarding: new-starter form โ IT + HR + Facilities tickets + a task to chase them. Change: flag a high-risk change for CAB review, raise an implementation task when one is approved. Assets: chase the acceptable-use paperwork when kit is issued, wipe-and-restock when it comes back. Also: root-cause investigation on a new problem, supplier due diligence, contract setup, a ticket when a morning check fails, a shadow-IT alert on newly discovered software, an alert when a CI is deleted, an orphaned-link check when an article is archived, and announcing a published article.
Four are time-based โ the recipes that were impossible before the scheduled triggers landed:
| Recipe | Trigger | What it does |
|---|---|---|
| Escalate before the SLA breaches | sla.warning |
Raises priority and alerts the team while there's still time to save it. |
| Alert when an SLA is breached | sla.breached |
Says so out loud, rather than letting it pass unremarked. |
| Contract renewal reminder | contract.expiring |
Raises a ticket 30 days out, so renewal is a decision rather than a discovery. |
| Asset warranty expiry reminder | asset.warranty_expiring |
Extend it or plan the replacement while the machine is still covered. |
โ ๏ธ These four need the crons scheduled โ see Time-based triggers. Without them the workflow sits there, active, and never fires. The two expiry recipes fire at 90 / 30 / 7 / 1 days out and each ships filtered to the 30-day window (window_days equals 30); change or drop that condition to be told at every window.
Pick one and it's copied in as a normal, fully editable workflow. There's nothing special about it afterwards โ rename it, rewire it, delete it.
Action args like status_id are numeric IDs that differ per install. A recipe cannot hardcode status_id: 5 โ your "Closed" might be 5, mine might be 9, and someone else calls it "Done".
So a template declares its lookups by name, and the resolver maps them to this install's real IDs at clone time:
'value' => ['$lookup' => 'ticket_status', 'names' => ['Closed', 'Resolved', 'Complete', 'Done']],Candidate names are tried in order, so a desk with both Critical and High correctly gets Critical for P1. Values only the user can supply (a Slack webhook URL) use a ['$configure' => 'โฆ'] marker instead.
Anything that can't be resolved is cloned blank and reported in an unresolved list, which the editor renders as a "these fields still need you" banner. A recipe always clones โ it just tells you honestly what it couldn't fill in, rather than silently half-working.
Cloned workflows arrive inactive. Nothing a recipe contains can fire until it's been read, filled in and switched on.
Every template's trigger and action types are checked against the live engine catalogues โ a recipe that couldn't actually run would be worse than no recipe.
Key file: workflow/includes/templates.php
Until #95 every action ran in the dark. runInner() put each result into the execution row's step_log and nowhere else, so a workflow could create a ticket and its very next action had no way to name it. "Raise a ticket, then email the requester about it" was unbuildable โ and unbuildable as two workflows too, because create_ticket dispatches no event of its own.
A successful action's result is now merged back into the payload under two names:
| Merge code | Reaches |
|---|---|
{{last.ticket_id}} |
the action immediately before this one |
{{steps.1.ticket_number}} |
a particular action, by the number shown beside it |
last covers the common two-action chain; steps is what a third action needs, because by then last is the second action's result.
๐ Merged under their OWN keys, never into the payload's natural namespace. Writing a new ticket's id over
ticket.idwould silently repoint every later{{ticket.*}}on a ticket-triggered workflow at a different ticket from the one that fired it โ a workflow that had been correct for a year would quietly start emailing about the wrong record.
โ ๏ธ stepsis one-based, matching the number the editor prints next to each action. A merge code that disagreed with the number on screen would be wrong in the one place people copy it from.
โ ๏ธ Nothing is merged on a dry run (nothing ran, so a later action's preview shows the merge code unresolved โ which is honest) or after a failed action (the chain stops there anyway).
Both prefixes are returned by variablePrefixes() for every trigger, since what an earlier action produced depends on the actions chosen, not on what fired the workflow.
FreeITSM had two implementations of "turn a form submission into a ticket", and they disagreed:
catalogueCreateTicketFromSubmission() |
action_create_ticket() |
|
|---|---|---|
| Requester | resolved by users.id
|
matched by email string, upserting |
| Company | from the requester's tenant_id
|
not set at all |
| Body | answers as a fully-escaped table | whatever the author typed |
| Reachable by | approval only, no config | any trigger, fully configurable |
So the configurable path produced the worse ticket. action_create_ticket() now delegates to the shared path whenever the payload carries a submission.id, with the configured args applied on top as overrides.
๐ The rule is: an override always wins, and the submission fills in whatever was left blank. That single sentence is what lets one function serve the approval path (which configures nothing) and a form's own action list (which may configure everything).
โ ๏ธ A blank body now means "show me what they asked for" โ the escaped answer table โ rather than an empty ticket. That is a behaviour change for any existingform.submittedworkflow that left the body empty, and a strict improvement.
โ ๏ธ The engine must lazilyrequire_onceincludes/catalogue_approvals.phpinside the handler. Both that file andincludes/services/forms.phprequire the engine at file level, so a top-level require back would be circular. Same idiom as the notifications router and the search indexer.
Anything with no submission in its payload โ a fan-out from ticket.created, say โ keeps the original behaviour untouched.
send_email took its mailbox from the ticket, so it could not acknowledge anything that was not yet a ticket. It now takes either a ticket or a mailbox_id, with an explicitly chosen mailbox winning over the ticket's own.
โ ๏ธ No ticket means no reply marker.buildTemplateEmailBody()appends "please reply above this line" over an[*** SDREF:โฆ ***]token, which is what threads a reply back onto the ticket. With no ticket the token is empty, so the trailer would invite a reply that lands nowhere โ a standalone send usesplainEmailBody(), which is the same styling without the marker.
โ ๏ธ templateSaveSentEmail()is skipped without a ticket (there is no conversation to file the copy against), butemailLogSent()still records the send, so a standalone message is auditable in Email send log โ it just isn't attached to anything.
mailboxis an action lookup only, never a condition field: nothing dispatches a mailbox id in a payload, so there would be nothing to compare against.
Dry run (toolbar, beside Test fire) evaluates the workflow's conditions for real, then describes each action instead of executing it.
The run is logged like any other but flagged is_dry_run, and each step records would_run (the action's catalogue label) plus would_args โ the args with {{variables}} already substituted. So you read the actual email subject, the actual chat message, the actual note. Not the template: the finished text.
Nothing is written, emailed, queued or sent โ no ticket mutations, no notes, no webhook_deliveries rows โ and the workflow's run_count / last_run_datetime are untouched. A dry run is not a run. Any signing secret is redacted rather than echoed back, so the debugging surface can't be used to read a secret out of a saved workflow.
That makes it safe to point at a live workflow, and it's the sensible first thing to do with a freshly cloned template.
Dry run vs Test fire. Test fire actually executes โ it will genuinely send that email. Dry run won't. The help page separates them explicitly, because the distinction matters the first time you point one at production.
Any free-text action arg supports {{path.to.field}} placeholders, resolved against the dispatch payload at execution time via the engine's renderTemplate() helper.
You don't type them by hand. Every variable-friendly field has an Edit with variables button that opens a roomy editor with a searchable variable list beside it โ click one and it drops in at the cursor. A live preview shows the finished text with each code swapped for what it stands for.
renderTemplate() resolves an unknown path to an empty string. It does not error.
So a workflow on knowledge.published whose message says {{ticket.subject}} doesn't fail โ it quietly posts a blank where the subject should be, and nobody notices. There is no ticket in a knowledge event.
WorkflowEngine::availableVariables($trigger) is the catalogue of codes a given trigger can actually resolve. The picker offers only those, and a hand-typed code that can't resolve is flagged under the field ("will come out blank โ not available on this trigger").
This catalogue is deliberately distinct from availableFields(), which answers a different question โ "what can I write a condition on" (scalars only). {{event}} is a valid merge code and a useless condition field; {{ticket.full}} is a whole JSON object, meaningless in a comparison.
{{ticket.priority_id}} renders as 4. That's fine for a receiving system that expects an id, and useless in a message a person reads โ nobody in your Slack channel knows what priority 4 is.
So every lookup field has a readable twin:
| Code | Renders |
|---|---|
{{ticket.priority_id}} |
4 |
{{ticket.priority_name}} |
Critical |
The picker gives the name the plain label (Ticket ยท Priority) and marks the numeric one (id). Labelling them the other way round would hand the natural-sounding name to the field that renders a meaningless number.
The twins are derived from the same FIELD_LOOKUP_TABLES registry that powers the condition dropdowns โ so adding a new lookup field gets you its name code for free, with no second list to maintain. That includes {{old_priority_name}} / {{new_priority_name}} on the change triggers, so "escalated from High to Critical" writes itself.
Resolution happens once per run (enrichWithLookupNames(), cached), before conditions and actions โ so the step log, the dry-run preview and every action all see the same enriched payload. It never clobbers a value the host module set deliberately: form.submitted already ships a real form.name, and that wins over anything derived from form.id.
Same idea, one level up. {{ticket.id}} is the internal database id (83). The thing a requester quotes back at you โ and the thing that belongs in an email subject or a chat alert โ is the ticket number (ETC-623-64409).
| Code | Renders |
|---|---|
{{ticket.id}} |
83 |
{{ticket.number}} |
ETC-623-64409 |
Also {{problem.number}}. Changes and tasks have no reference column, so they're correctly offered none โ their id is their identity today.
Named number, not "ref": the app and the schema already call it a number (tickets.ticket_number), and a third word for the same concept is just a synonym to learn.
โ ๏ธ One exception, and it matters. A Ticket ID action argument must stay{{ticket.id}}. That field tells the engine which record to act on, and it looks it up by row id โ feed it a ticket number and the action fails. The rule: the number goes in the words a person reads; the id stays in the plumbing. The starter templates follow exactly this split.
It also offers:
-
{{event}}โ the name of the trigger that fired. -
{{<object>.full}}โ e.g.{{ticket.full}}, the entire record as JSON, in the exact shape the REST API returns it (for objects the engine can hydrate whole). -
{{submission.fields.<your form's label>}}โ an open-ended shape for form answers, which can't be enumerated because they're keyed by whatever the form author called them.
Labels are derived from the path (ticket.requester_email โ "Ticket ยท Requester email") rather than hand-mapped, so they can't rot as the 138-trigger catalogue grows.
See Workflow & Webhook Pitfalls #1 and #2 โ the
send_webhookaction's own default body template shipped referencing{{event}}, which the engine never populated, so every custom webhook built from the default had been sending"event": "". That bug was found by building this picker.
Common variables for ticket triggers:
-
{{ticket.id}},{{ticket.subject}},{{ticket.priority_id}},{{ticket.status_id}} -
{{ticket.department_id}},{{ticket.type_id}},{{ticket.assigned_analyst_id}} -
{{ticket.owner_id}},{{ticket.created_by}},{{ticket.requester_email}} - For
ticket.status_changed/ticket.priority_changed: also{{old_status_id}},{{new_status_id}},{{old_priority_id}},{{new_priority_id}} - For
ticket.assigned: also{{analyst_id}},{{team_id}}
In ticket_id fields, the default {{ticket.id}} is pre-filled โ leave it as-is so the workflow operates on whichever ticket triggered it. Specifying a literal id hard-codes the workflow to one ticket.
Key methods: WorkflowEngine::availableVariables($trigger) (the merge-code catalogue), variablePrefixes($trigger) (open-ended shapes like submission.fields.).
Toolbar button (amber-tinted, sparkle icon) opens a modal where the user describes the workflow in plain English. The backend (api/workflow/_ai_helpers.php + api/workflow/ai_compose.php) sends a system prompt that contains the live engine catalogues โ every trigger, condition operator, action, and the per-action arg schemas with the first 8 id=label pairs of every lookup table โ so the AI can only propose things the engine actually understands.
The model returns a JSON proposal that's validated server-side against the same catalogues. Unknown triggers fall back to the first known one, unknown operators / actions are dropped with warnings surfaced to the client. The modal shows the AI's explanation, a colour-coded preview of the proposed workflow, any warnings. Apply replaces the canvas nodes with the proposal; Discard lets the user try again. When the canvas already has content, the AI iterates on it โ "now only match Finance" / "add an action to log the ticket id too" edits what's there rather than starting over.
Provider-aware โ works with both Anthropic and OpenAI (and falls back to either if you have one of them configured). Settings page at workflow/settings/ lets you pick the provider, model, API key (encrypted, masked) and verify-SSL toggle per module, so installs can route different modules' AI calls to different accounts for granular billing.
The Test fire button runs the workflow with a synthetic payload built from its own conditions โ the editor sets payload[field] = condition.value for each equality op (or sentinels for is_empty etc.), so the conditions trivially pass and the action path actually executes. The result lands in Recent runs on the workflow detail panel with status (success / failed / skipped) and full step log.
To test against real data, just do the thing that triggers the workflow โ assign a ticket, change its priority, etc.
A test fire is logged as a normal execution, but it doesn't increment the workflow's run_count counter โ so the production metrics stay clean however many times you test.
The engine deliberately swallows its own errors. A broken workflow must never break the ticket save that triggered it, so dispatch() catches everything and writes it to the execution row rather than letting it propagate.
That is the right call. It also means a workflow can fail on every single fire and nothing anywhere tells you.
Two things break that silence.
Every run of every workflow. Before this, the only view of a run was the editor's Recent runs panel โ the last 20 runs of one workflow, no filters, no search, no history. That's the wrong shape for the question you actually ask when something breaks ("what failed, and why?"), because you don't yet know which workflow to open.
- Filter by workflow, status, trigger, date range, dry-run vs real, or free text over the workflow name / trigger / error message.
- Status tallies double as one-click filters โ the failure count is also the button that shows you the failures.
- Filters live in the URL, so a view is bookmarkable and shareable. That's what lets the Watchtower card link straight to today's failures instead of dropping you at an unfiltered list.
Open a run and you get what the engine actually did:
| Conditions | Which matched and which didn't โ with the actual value it compared against. So a skipped run finally explains itself: `ticket.subject contains "urgent" |
| Actions | What each one returned, or exactly how it failed. Dry runs show would_run + the resolved args. |
| Webhooks | Any deliveries the run queued, with their status โ bridging the execution log and the delivery log, which are otherwise two separate hunts. |
| Trigger payload | The snapshot captured at run time: exactly what the conditions were tested against and what the {{variables}} resolved from. Usually the single most useful thing on the page. |
Runs stay attributable after their workflow is deleted โ the workflow name is snapshotted onto the row (workflow_name), so history survives and remains searchable by name even though workflow_id goes NULL.
The dashboard-level answer to the same problem. Over the last 24 hours:
- Failed runs โ the workflow errored.
- Aborted runs โ loop protection refused to run it (see Engine internals).
- Dead-lettered webhooks โ and this one is easy to miss: the workflow succeeded. It queued the send. The message simply never arrived, after every retry. Nothing else in the product would ever surface that.
Red on failures or aborts, amber on dead webhooks alone, green when clear.
It doesn't just count โ it names the failing workflows and shows what the error said. "3 failures" sends you hunting; "Unknown status_id: 999999" usually answers the question where you're standing. Every line deep-links into the pre-filtered execution log.
Dry runs are excluded. A dry run is a person testing something, and its failures are expected โ not an incident.
Triggers are dispatched from host modules' save flows. The catalogue now spans every module (138+ triggers โ see the trigger catalogue notes above and the Webhooks event list): rich domain events like ticket.created, form.submitted, task.completed, change.approved, problem.created, knowledge.published, service_status.incident_created, morning_check.recorded, software.application_discovered and the project events (project.health_changed, project.tolerance_breached, project.stage_closed, project.stage_due, 3.2.0), plus generated .created/.updated/.deleted triggers for every reusable entity and settings lookup. The original four ticket triggers illustrate the wiring pattern:
| Trigger | Wired from | Notes |
|---|---|---|
ticket.created |
api/tickets/create_ticket.php |
After commit; reads back resolved status_id/priority_id so the payload carries real ids (the INSERT uses name-based subselects). |
ticket.status_changed |
api/tickets/assign_ticket.php |
When the status_id changes. Payload carries the full post-update ticket state plus old_status_id / new_status_id. |
ticket.priority_changed |
api/tickets/assign_ticket.php |
When the priority_id changes. Payload carries the full post-update ticket state plus old_priority_id / new_priority_id. |
ticket.assigned |
api/tickets/assign_ticket.php |
When the assigned_analyst_id changes. |
Each dispatch is wrapped in a defensive try/catch on the host-module side (engine already swallows internally โ belt+braces so an engine outage can't break the host module's response that's already been echoed). UI settings endpoints emit via the best-effort wf_emit() helper; shared services call the engine directly.
WorkflowEngine::dispatch($event, $payload) โ host modules call this
โ SELECT * FROM workflows WHERE trigger_event = ? AND is_active = 1
โ for each matching workflow:
โ insert workflow_executions row (status = 'running')
โ evaluate conditions (AND); if any fail, status = 'skipped'
โ else: foreach action, executeAction(type, args, payload)
โ renderTemplate() resolves {{vars}} in free-text args
โ handler (one of the 8) does the work
โ if it throws, mark step failed, stop the chain
โ update workflow_executions with final status + step_log
โ update workflows.last_run_* + run_count
Failures are isolated. A buggy workflow cannot break the host module's request. Engine-level errors go to PHP error_log; per-action errors go to the execution row's step_log; neither propagates.
Infinite-loop protection. Workflow actions mutate host data that re-dispatches events, so a workflow could trigger itself โ directly (A fires an event that re-runs A), via a cycle (A โ B โ A), or via fan-out. Because execution is synchronous within one request, the engine guards every run with three request-scoped counters: an active-workflow stack (a workflow already running in the current chain is refused re-entry โ catches every cycle), a chain-depth ceiling (MAX_CHAIN_DEPTH, the backstop for deep acyclic cascades), and a per-request run ceiling (MAX_RUNS_PER_REQUEST, the backstop for fan-out). A blocked run is recorded as an aborted execution row with the reason, so the block shows up in the audit โ and on the Watchtower card โ rather than failing silently.
Synchronous execution in v1 โ no job queue. Keep actions fast and idempotent. Async + retry land when the first slow external action (Microsoft Graph) requires them.
| File | Purpose |
|---|---|
workflow/includes/engine.php |
The whole engine: dispatch / manualFire / 4 catalogue methods / 8 action handlers / variable substitution. |
workflow/editor.php |
Canvas page. Exports engine catalogues + lookup values + action lookups to JS. |
workflow/index.php |
List view (tickets-settings chrome). |
workflow/settings/index.php |
Per-module AI settings tab (provider / model / key / Test). |
workflow/help.php |
The user-facing help guide. |
assets/js/workflow-editor.js |
Canvas state, render loop, drag/snap, selection, detail panel, action arg form renderer, AI co-author modal. |
assets/css/workflow.css |
Canvas / node / detail-panel / status-pip / AI modal styling. |
api/workflow/list.php / get.php / save.php / delete.php / fire.php
|
List CRUD + Test fire. |
api/workflow/_ai_helpers.php / ai_compose.php / get_ai_settings.php / save_ai_settings.php / test_ai_key.php
|
AI co-author + per-module AI settings. |
| Table | Columns of note |
|---|---|
workflows |
id, name, description, trigger_event, conditions (TEXT, JSON), actions (TEXT, JSON), is_active, created_by, last_run_datetime, last_run_status, run_count, created_datetime, updated_datetime
|
workflow_executions |
id, workflow_id, trigger_event, trigger_payload (TEXT, JSON), status (running / success / failed / skipped), step_log (TEXT, JSON โ per-step audit), error_message, started_datetime, finished_datetime
|
-
Webhooksโ done, all phases: thesend_webhookaction, async delivery (queue, retries/backoff, dead-letter, delivery log with Replay), Send-test button, full-object payloads, the 138-trigger catalogue, and the overview dashboard. See Webhooks. -
Teams / Slack messagesโ done via thesend_webhookaction (Slack/Teams/Discord presets). -
Dry-run modeโ done. See Dry run above. -
Starter recipesโ done. See Starter templates above. (SLA-breach escalation and licence-renewal reminders are not in the shipped set โ neither has a wired trigger yet; see below.) -
Tier 3 actions โ Microsoft Graph:
graph_add_to_group,graph_assign_license,graph_disable_user. The new-starter / leaver automations that justify the module's existence vs paid ITSM suites. Reuses the existing tenant OAuth scaffolding from the mailbox integration. -
SLA + contract-renewal triggersโ done. Four time-based triggers (sla.warning,sla.breached,contract.expiring,asset.warranty_expiring), a fire-once ledger, and the four starter templates that were previously impossible. See Time-based triggers above. -
Cross-workflow execution logโ done. See The execution log above. -
Watchtower integrationโ done. Failed runs, loop-protection aborts and dead-lettered webhooks surface as an attention card. - Workflow versioning โ draft / publish / rollback.
- Streaming AI co-author โ claude.ai-style live output as the canvas builds.
- Time-Based Triggers โ SLA, contract and warranty triggers: the crons they need, the fire-once ledger, and how to add one of your own.
- Webhooks โ the outbound-webhook deep dive (formats, signing, delivery engine, data protection).
-
HTTPS Certificates & CA Bundles โ why
unable to get local issuer certificateis almost never your webhook's fault. - Workflow & Webhook Pitfalls โ real bugs found in this code, what caused them, and the general shape of each.