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 or approving team - it carries approver.id or, since 4.3.0, approval_team.id and approval_team.approvals_required - 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. For a team request they fire once, when the request is decided, not on each vote.
โ ๏ธ 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, which ignore capitals and the spaces around the typed value (textContains(), #214). 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 / category_id) plus event-specific extras (old_status_id / new_status_id / old_priority_id / new_priority_id / old_category_id / new_category_id / analyst_id / team_id).
Routing tickets by category (#298)
Category (ticket.category_id) is a condition on every ticket trigger, so "a new ticket in Hardware" is one condition on A ticket is created. It works on triggers whose event never mentioned a category too (a reply received, a note added, an SLA warning): the engine looks the ticket's category up when the event does not carry it.
Tickets that arrive by email usually have no category until someone triages them, so a second trigger catches that moment: A ticket's category changes (ticket.category_changed). It fires when someone sets, changes or clears a ticket's category - from the reading pane, the workspace, the REST API or another workflow - and not when the same category is saved again. Its event carries old_category_id and new_category_id as well as the whole ticket.
Subcategories. Ticking a category in a condition also matches everything beneath it, so a workflow for Hardware runs for a ticket in Hardware โ Laptop (and Hardware โ Laptop โ Battery). That is the default. An install that would rather name every subcategory it wants sets Tickets โ Settings โ Categories โ Categories in workflows to It matches that category only. The setting is install-wide and applies to every workflow at once; the editor shows a tip under a category condition saying which way it is set, with a link to change it. When a match included subcategories, the run history's condition step says so (includes_subcategories).
- Workflows โ New workflow, name it Hardware to Desktop.
- Trigger: A ticket is created.
- Condition: field Ticket ยท Category, tick Hardware. With the default setting this also catches Hardware โ Laptop, Hardware โ Printer and the rest.
- Action: Assign ticket to team, team Desktop. Leave the ticket id as
{{ticket.id}}. - Save, and use Dry run against a Hardware ticket to see the condition pass.
That routes tickets raised with a category - the portal, the New ticket form, the REST API. For email tickets categorised at triage, make a second workflow the same way with the trigger A ticket's category changes. A ticket created in Hardware and later moved to another Hardware subcategory runs the second workflow as well - harmless for an assignment, but worth knowing before adding a note or email action.
Changing the type. Changing a ticket's type clears a category that belonged to the old type (see Ticket categories). Since 5.1.0 that clearing fires A ticket's category changes too, with the old category and no new one - so a workflow can catch a ticket leaving a category: condition Old category (old_category_id), tick it. Old category and New category are category pick-lists with full paths, like Ticket ยท Category, and follow the same subcategory setting. The merge codes {{old_category_name}} and {{new_category_name}} give their names.
Developers: Workflow category routing - Developer Guide.
Twenty-two handlers ship today - the eighteen below, plus attach_checklist, escalate_to_tracker, send_note_to_tracker and open_jml_case (Starters & leavers) (see the workflow engine developer guide for all of them and what each returns). 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.
Attach SOP Checklist(s) (attach_checklist) offers only checklists that apply to tickets (or to both) - a checklist made for tasks only cannot go on a ticket. A workflow saved before one of its checklists was changed to tasks only keeps working: that checklist is skipped, the others are attached, the actions after it still run, and the step leaves a note saying which checklist was skipped and what to change (see Runs that need a look below). Until 5.0.0 the same situation failed the whole run and stopped every action after it.
| 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 |
Write it as A note on the ticket - a real note in the ticket's Notes, by System, internal unless Share with the requester is ticked - or History only - a Workflow Note row in ticket_audit, as before (#268). New actions default to a note; actions saved before 5.0.0 keep History only. Never emails anyone. |
ticket_id, note, write_to (note or empty = history), shared
|
send_email |
Sends an email using the ticket's mailbox, or the Send from mailbox when there is no ticket (that row is decrypted with decryptMailboxRow() like every other mailbox read - #216). 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, mailbox_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. Sets owner_id with assigned_analyst_id, and puts a mailbox on the opening message - the one chosen in Mailbox, else the mailbox of the ticket that fired the rule - so the ticket can be replied to (#216). |
subject, body, priority_id, department_id, type_id, assigned_team_id, assigned_analyst_id, mailbox_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
|
assign_ticket_team (4.1.0)
|
Puts the ticket in a team's queue (assigned_team_id); the assigned analyst is left alone. Goes through TicketsService::updateTicket(), so it is audited and fires ticket.team_assigned like a change on screen. |
ticket_id, team_id
|
set_ticket_department (4.1.0)
|
Changes the department - through the same service, so a confidential department makes the ticket confidential. |
ticket_id, department_id
|
set_ticket_category (4.1.0)
|
Changes the category - only to one the ticket's company uses and, when the category belongs to a ticket type, one for the ticket's type. Anything else fails the step and says why. |
ticket_id, category_id
|
set_ticket_type (4.8.0)
|
Changes the type - only to one the ticket's company uses. For a type that changes later in a ticket's life; the type a new ticket starts with is set under Tickets โ Settings โ Ticket types (A type for every new ticket). The ticket number stays as it is, and a category belonging to another type is cleared, as on screen. |
ticket_id, ticket_type_id
|
set_ticket_field (#278)
|
Sets one of the ticket's custom fields (Tickets โ Settings โ Custom fields). The value can use {{merge codes}}, so a field can be worked out from other fields or from an earlier step - e.g. a reference ACME-{{ticket.number}}. It is checked by the field's type exactly as on screen (a number field refuses text, and the step fails saying so), it must be a field the ticket has for its company and type, and the change is recorded in the ticket's history. An empty value clears the field. For a value that lives in another system, let that system write it through the REST API's custom_fields. |
ticket_id, field_key, value
|
set_task_status (#274)
|
Changes a task's status through TasksService::saveTask(). A closed status completes it exactly as on screen: required checklist steps or forms set to block stop it, and the step fails saying why. Fires task.status_changed / task.completed; the task's history says a workflow did it. |
task_id (starts as {{task.id}}), status_id
|
assign_task (#274)
|
Makes an analyst the task's owner - they are told as if it had been assigned on screen. The analyst must be able to see the workflow's company. |
task_id, analyst_id
|
add_task_comment (#274)
|
A comment on the task by System; everybody on the task is told, as for any comment. |
task_id, comment
|
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.
Task workflows and their ticket (#274)
Every task trigger carries the ticket the task belongs to, as task.ticket_id - a condition field (Task ยท Ticket) and a merge code - with {{task.ticket_number}}, {{task.ticket_subject}} and {{task.ticket_url}} beside it.
- A ticket action added to a task workflow starts with
{{task.ticket_id}}instead of{{ticket.id}}, so "when a task is done, add a note to its ticket" needs nothing typed. Change the trigger and a starting value follows it; a value you typed is left alone. - Add the condition Task ยท Ticket is not empty so the rule skips tasks that have no ticket. Without it, a ticket action on such a task fails with "This task is not linked to a ticket" rather than a puzzling "ticket_id is required".
- The ticket actions keep their company check: a company's own workflow cannot reach another company's ticket through a task.
- The recipe When a task is done, note it on its ticket (category Tasks) is the example from the issue, ready to switch on.
- Developers: Workflows for tasks - Developer Guide.
- Loops are stopped by the engine: a workflow on
task.status_changedthat sets a task status, or ontask.comment_addedthat adds a comment, is refused re-entry.
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.
Microsoft Teams, today. Microsoft switched the Office 365 connector webhooks off in May 2026. In Teams, a channel's address now comes from Workflows โ "Post to a channel when a webhook request is received", and the format to choose is Microsoft Teams (Workflows card) - an Adaptive Card with an Open in FreeITSM button on {{link}}. Microsoft Teams alerts builds these workflows from a page of ticks.
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 (6 by default; the tries, the wait for the endpoint and what happens to a send cut off part-way are set on System โ Webhooks โ Delivery). 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 27 ready-made recipes for the jobs most service desks automate first, across 15 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. Tasks: when a task is done, note it on its ticket (#274). 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. -
{{link}}โ the page the event is about, as a full address: the ticket when there is one, otherwise FreeITSM's front page. Never empty, so it is safe as a chat card's button. Also{{ticket.url}}(that ticket) and{{app.url}}(the front page). Built from the public base URL byenrichWithLinks(). -
{{<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). Any field of it works by path, in every action (#275):{{task.full.ticket_id}}is the ticket a task is linked to,{{ticket.full.requester.email}}the requester's address. Before #275.fullwas only loaded for Send webhook, so these were blank anywhere else. It is loaded only when an action uses.full, and never for a confidential ticket - the same rule the webhook follows. -
{{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}} - For
ticket.created: also{{ticket.assigned_team_id}}/{{ticket.assigned_team_name}}โ the team it arrived with - For
ticket.team_assigned: also{{team_id}}/{{team_name}}โ the team it was handed to.โ ๏ธ Notticket.assigned_team_id: this event does not carry it, and a condition on it never matches.
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.
4.1.0 (#209). Only on an install with more than one company - with one, nothing here appears and nothing changes.
Every workflow is either shared or one company's own, chosen under Runs for in the editor.
| Shared (All companies) | One company's own | |
|---|---|---|
| Runs for | every company's events - exactly what every workflow did before 4.1.0, and what every existing workflow still is after the upgrade | only events whose ticket, task, change, problem, asset, CI, project, domain, contract or form submission belongs to that company |
| Its actions may touch | anything, as before | only that company's records. Naming another company's ticket, a mailbox or checklist pinned to another company, or handing work to someone who cannot see the company fails the step, and the run log says why |
| Who can create, change, test or delete it | someone who can see every company - it acts on all of them | anyone who can see that company |
| Who can open it | anyone with Workflow access (read-only, webhook URLs and secrets hidden, unless they can see every company) | anyone who can see that company |
The list follows the company switcher: the shared workflows plus those of the company you are in. An event that belongs to no company - a supplier being saved, a status renamed - runs only the shared workflows.
The run log follows the event. Each run is filed under the company its event belonged to and shown only to people who can see that company. A run with no company (a settings event, or a run from before 4.1.0) is shown to people who can see every company.
A shared workflow can still treat companies differently. Every trigger gains a Company condition field and {{company.id}} / {{company.name}} merge codes. But a condition is not a boundary: anyone who can edit the workflow can change it. To keep a workflow to one company, set Runs for.
Created records follow the company too: a ticket or task raised by a company's workflow is that company's, and a task a shared workflow links to a ticket takes the ticket's company (before 4.1.0 every workflow task landed in the Default company). How it is built: Workflow engine - Developer Guide.
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. An action that could only do part of its job shows its note in an amber box on the step. |
| 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. |
(5.0.0) Sometimes an action can do only part of what it was asked, for a reason somebody should fix - Attach SOP Checklist(s) naming a checklist that is now for tasks only is the first case. The run is not a failure: it succeeds, and the later actions still run. But the step carries a note, shown in amber, saying what was skipped and what to change (Not attached - for tasks only, so it cannot go on a ticket: "New starter kit". Change the workflow, or make the checklist for tickets too.). If the action did nothing at all, the step shows as skipped; if it did some of its job, as success.
To find them, choose Needs a look (an action left a note) in the execution log's status filter (?status=attention in the address). The Watchtower card below counts them too.
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.
- Runs that left a note (5.0.0) - the run succeeded, but an action skipped part of its job (see Runs that need a look above). Shown as "N run(s) skipped part of their job and left a note - latest: Onboarding kit" (the newest such workflow), linking to the execution log filtered to Needs a look.
Red on failures or aborts, amber on dead webhooks or runs that left a note, green when clear. A run that left a note stops the card saying No workflow failures.
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.
Actions run synchronously, webhooks do not. Every action runs inside the event that triggered it, so keep actions fast and idempotent - except send_webhook, which queues a delivery that a scheduled worker sends with retries (see Outbound webhooks in practice). Async + retry land when the first slow external action (Microsoft Graph) requires them.
| File | Purpose |
|---|---|
workflow/includes/engine.php |
The whole engine: dispatch / manualFire / the catalogue methods / 22 action handlers / the company guards / variable substitution. How it is built: Workflow engine - Developer Guide. |
includes/workflow_tenancy.php |
Workflows and companies (4.1.0): which company an event belongs to, and who may see, change and fire which workflow and run. See Companies. |
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 / save_ai_settings.php / test_ai_key.php
|
AI co-author + per-module AI settings. |
The two main ones below; the engine also uses workflow_scheduled_emissions (time-based triggers), webhook_deliveries (the outbound queue) and webhook_message_formats - all five are in the developer guide.
| 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, tenant_id (4.1.0: NULL = shared, runs for every company; set = that company only - see Companies) |
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, tenant_id (4.1.0: the event's company; NULL = none known, shown only to people who can see every company) |
-
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.
- ๐ ๏ธ Workflow notes on a ticket - Developer Guide
- ๐ ๏ธ Workflow category routing - Developer Guide
- 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.