Workflows - edmozley/freeitsm GitHub Wiki

Workflows

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).

What makes a workflow run โ€” and what you have to schedule

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.

1. Detecting the event โ€” nothing is scheduled

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.

2. Delivering a webhook โ€” this DOES need a scheduled task โš ๏ธ

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.

3. Time passed โ€” a scheduled job goes looking

"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.

The summary

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.

Time-based triggers โ€” and the problem of not firing twice

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.

Anatomy

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.id had an entry in FIELD_LOOKUP_TABLES all along, reading a column called name โ€” the forms table has title. The query threw, availableValuesForField() caught it and returned null, 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 to form.id stops 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 on trigger_event and the editor can only offer what availableTriggers() lists โ€” so a dispatch() 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.

Visual canvas builder

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.

Conditions

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 with op: in, value: ["4", "3"] rather than two AND-ed conditions which doesn't actually express OR.
  • FIELD_TYPES โ€” drives operator filtering. Numeric fields offer equals / not_equals / in / not_in / gt / lt / is_empty / is_not_empty (no contains โ€” substring search on a number is meaningless). Text fields offer the same minus gt / lt (lexicographic string compare is a footgun) plus contains / not_contains, which ignore capitals and the spaces around the typed value (textContains(), #214). Lookup fields collapse to is / 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).

Worked example - assign new Hardware tickets to the Desktop team

  1. Workflows โ†’ New workflow, name it Hardware to Desktop.
  2. Trigger: A ticket is created.
  3. Condition: field Ticket ยท Category, tick Hardware. With the default setting this also catches Hardware โ†’ Laptop, Hardware โ†’ Printer and the rest.
  4. Action: Assign ticket to team, team Desktop. Leave the ticket id as {{ticket.id}}.
  5. 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.

Actions

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_changed that sets a task status, or on task.comment_added that adds a comment, is refused re-entry.

Outbound webhooks in practice

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).

Starter templates

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.

The design problem a recipe library has to solve

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


Chaining โ€” what an earlier action produced

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.id would 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.

โš ๏ธ steps is 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.

One ticket, however a form raised it

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 existing form.submitted workflow that left the body empty, and a strict improvement.

โš ๏ธ The engine must lazily require_once includes/catalogue_approvals.php inside the handler. Both that file and includes/services/forms.php require 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.

Sending an email without a ticket

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 uses plainEmailBody(), which is the same styling without the marker.

โš ๏ธ templateSaveSentEmail() is skipped without a ticket (there is no conversation to file the copy against), but emailLogSent() still records the send, so a standalone message is auditable in Email send log โ€” it just isn't attached to anything.

mailbox is 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

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.


Variable substitution (merge codes)

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.

The list is scoped to the trigger โ€” and that's load-bearing

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.

Use the name, not the id

{{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.

Quote the reference, not the row 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 by enrichWithLinks().
  • {{<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 .full was 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_webhook action'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. โš ๏ธ Not ticket.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.).

AI co-author

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.

Companies

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.

Test fire

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 execution log โ€” and why it has to exist

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.

workflow/executions.php

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.

Runs that need a look

(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 Watchtower card

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.

Host module trigger wiring

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.

Engine internals

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.

Key files

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.

Key tables

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)

What's still ahead

  • Webhooks โ€” done, all phases: the send_webhook action, 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 the send_webhook action (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.

See also

โš ๏ธ **GitHub.com Fallback** โš ๏ธ