From 91579162c7040f685503e3c6805fa3f11e5e9cb7 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:09:40 -0400 Subject: [PATCH 01/10] docs: add Workflows section Covers the event-driven workflow feature that complements the existing scheduler-driven Automations: - How workflows differ from Automations (reactive vs periodic) - Full trigger-event catalog (ticket.created, reply.created, sla.*, etc.) - Condition reference (status, priority, tag, email, hours_open, ...) - Action catalog with all 12 actions including the newly-shipped `delay` action and its deferred-job queue semantics - Template variable interpolation for canned replies - Round-robin (least-loaded) assignment behavior - send_webhook payload shape - Three end-to-end example workflows (VIP tagging, chase stale tickets, subject triage) - Workflow logs + admin UI + scheduler dependency notes - Automations-vs-workflows decision table Wires the new page into docs.json between the Automations and Followers entries so the sidebar reflects the relationship. --- docs.json | 6 ++ sections/workflows.md | 197 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 sections/workflows.md diff --git a/docs.json b/docs.json index 7891a9d..471d00a 100644 --- a/docs.json +++ b/docs.json @@ -90,6 +90,12 @@ "anchor": "automations", "type": "single" }, + { + "slug": "workflows", + "label": "Workflows", + "anchor": "workflows", + "type": "single" + }, { "slug": "followers", "label": "Followers", diff --git a/sections/workflows.md b/sections/workflows.md new file mode 100644 index 0000000..6470b18 --- /dev/null +++ b/sections/workflows.md @@ -0,0 +1,197 @@ +# Workflows + +Workflows are event-driven automation rules that run in response to ticket lifecycle events (new ticket, status change, SLA breach, inbound reply, etc.). Unlike [automations](automations.md), which run on a recurring scheduler, workflows fire **the moment the triggering event happens** -- an agent assigning a ticket, a customer replying, an SLA tipping over. + +Use workflows when you want reactive side effects. Use automations when you want periodic sweeps of the ticket database. + +## How workflows work + +Every workflow has three parts: + +1. **Trigger event** -- the event that starts evaluation (e.g. `ticket.created`, `reply.created`, `sla.breached`). +2. **Conditions** -- a filter evaluated on the ticket when the event fires. All conditions must match (AND logic). +3. **Actions** -- the side effects applied when conditions match. Actions run in order; a failure in one does not abort the rest (the error is logged to the workflow run history). + +When an event fires, Escalated dispatches it to the workflow engine, which looks up every active workflow subscribed to that event, evaluates conditions against the current ticket state, and executes each matched workflow's actions. The run is recorded in the workflow log for auditability. + +## Trigger events + +| Event | Fires when | +|---|---| +| `ticket.created` | A new ticket is created (via API, inbound email, widget, or agent UI) | +| `ticket.updated` | Any field on the ticket changes | +| `ticket.status_changed` | The ticket status transitions (e.g. `open -> solved`) | +| `ticket.priority_changed` | The priority changes | +| `ticket.assigned` | The `assignee_id` changes | +| `ticket.department_changed` | The ticket moves to a different department | +| `ticket.tagged` | A tag is added or removed | +| `ticket.reopened` | A closed ticket receives a new reply and is reopened | +| `reply.created` | Any reply is added (agent or customer) | +| `reply.agent_reply` | An agent-authored reply is added | +| `sla.warning` | A ticket nears its SLA target | +| `sla.breached` | A ticket breaches its SLA target | +| `inbound.received` | The inbound-email webhook parses a message | +| `signup.invite` | A guest submission under `prompt_signup` policy should trigger a signup invite | + +A workflow subscribes to exactly one trigger event. For rules that should react to multiple events, define a workflow per event. + +## Conditions + +Workflow conditions evaluate the ticket (and relevant context) at the moment the event fires. All conditions must match: + +- **`status`** -- ticket has a specific status slug +- **`priority`** -- ticket priority is one of `low`, `medium`, `high`, `urgent` +- **`ticket_type`** -- ticket type is `question`, `problem`, `incident`, or `task` +- **`subject_contains`** -- ticket subject includes a keyword (case-insensitive) +- **`description_contains`** -- ticket description includes a keyword +- **`assigned`** / **`unassigned`** -- ticket has (or does not have) an assignee +- **`department`** -- ticket belongs to a specific department +- **`has_tag`** / **`lacks_tag`** -- ticket has (or does not have) a specific tag +- **`requester_email_matches`** -- requester/contact email matches a pattern (glob or domain) +- **`hours_open`** -- ticket has been open for more than N hours +- **`from_channel`** -- ticket originated from a specific channel (`email`, `widget`, `api`, `chat`) + +Combine conditions freely. If you need OR semantics, create multiple workflows on the same trigger with different condition sets. + +## Actions + +Workflows can perform any of the following actions. Actions execute in the order they are declared: + +| Action | Purpose | +|---|---| +| `change_priority` | Set the ticket priority | +| `change_status` | Set the ticket status (accepts status slug or numeric id) | +| `set_department` | Move the ticket to a department | +| `add_tag` | Attach a tag (by slug or id); no-op if already present | +| `remove_tag` | Detach a tag; no-op if not present | +| `assign_agent` | Assign to a specific agent by id, writing a ticket-activity audit row | +| `assign_round_robin` | Assign to the least-loaded agent from a pool (see below) | +| `add_note` | Add an internal note visible only to agents | +| `insert_canned_reply` | Add a public reply with `{{field}}` template interpolation | +| `add_follower` | Add a user as a ticket follower (idempotent) | +| `send_webhook` | POST a JSON payload to a configured webhook | +| `delay` | Pause the workflow and resume remaining actions after N seconds (see below) | + +### Template variables + +`insert_canned_reply` and `add_note` support simple `{{variable}}` interpolation against the ticket. Available variables include: + +- `{{subject}}`, `{{priority}}`, `{{status}}` +- `{{requester_name}}`, `{{requester_email}}` +- `{{ticket_id}}`, `{{reference_number}}` +- Any custom field slug on the ticket + +Unknown variables are left as literal `{{name}}` so gaps are visible in the rendered reply rather than silently disappearing. + +### Round-robin assignment + +`assign_round_robin` accepts a list of agent ids (or "all agents in department X") and picks the **least-loaded** agent -- the one with the fewest currently-open tickets. This favors the agent most likely to be idle rather than rotating strictly. If two agents tie, the one with the lower id wins for determinism. + +### Delayed actions + +The `delay` action splits a workflow run into two halves. Actions before the delay run inline. Remaining actions are persisted to a deferred-job queue with `run_at = now + N seconds` and picked up by a scheduled poller (runs once per minute by default) after the wait elapses. + +Example: "When a ticket is created by a VIP, wait 5 minutes, then if the ticket is still open, add a note to ping the on-call manager." + +```yaml +trigger: ticket.created +conditions: + has_tag: vip +actions: + - add_tag: needs-fast-response + - delay: 300 # 5 minutes + - add_note: "Still open after 5 min -- page on-call." +``` + +The first two actions happen immediately. If the ticket is still open 5 minutes later, the note is added; if the ticket was already closed in the meantime, the note still fires (the delay does not re-evaluate conditions -- it resumes the saved action sequence verbatim). To make conditional resume-time behavior, fan out to a separate workflow trigger. + +The deferred-job queue retains rows after completion (status flips from `pending` to `done` or `failed`) so you can audit when and why each delay fired. + +## Webhook action + +`send_webhook` posts a JSON payload to a webhook you've configured under `Admin -> Webhooks`. The payload is: + +```json +{ + "event": "workflow.triggered", + "workflow_id": 42, + "ticket": { + "id": 1234, + "reference_number": "TICK-1234", + "subject": "...", + "status": "open", + "priority": "high", + "assignee_id": 7, + "requester_email": "alice@example.com" + } +} +``` + +Delivery follows the same retry + signing rules as other Escalated webhooks. Failures do not block subsequent actions in the same workflow. + +## Example workflows + +### Auto-tag and assign high-priority VIP tickets + +```yaml +trigger: ticket.created +conditions: + requester_email_matches: "@vip.example.com" +actions: + - change_priority: urgent + - add_tag: vip + - assign_round_robin: [3, 7, 11] # senior agents + - send_webhook: 9 # page PagerDuty +``` + +### Chase stale tickets + +```yaml +trigger: ticket.status_changed +conditions: + status: pending +actions: + - delay: 86400 # 24 hours + - add_note: "Pending > 24h. Consider following up." +``` + +### Triage by subject + +```yaml +trigger: ticket.created +conditions: + subject_contains: refund +actions: + - set_department: 5 # Billing + - add_tag: refund-request + - insert_canned_reply: "Hi {{requester_name}}, we've received your refund request (#{{reference_number}}) and will respond within 1 business day." +``` + +## Workflow logs + +Every workflow run -- match or no match, success or failure -- writes a row to the workflow log visible at `Admin -> Workflows -> Logs`. Each row records: + +- Trigger event, triggering ticket id, workflow id +- Whether conditions matched +- For each action: status (`done`, `skipped`, `failed`) and any error message +- For deferred actions: a link to the deferred-job row and its eventual outcome + +## Managing workflows + +Admins create and edit workflows at `Admin -> Workflows`. The UI mirrors the macro builder: pick a trigger, add conditions, add actions, reorder as needed. Workflows can be toggled inactive without deleting them. + +## Scheduler dependency + +The `delay` action and any other deferred behaviors depend on the application scheduler being live. See [Scheduling](scheduling.md) for how to enable the cron or queue worker your framework uses. If the scheduler is not running, delayed actions will sit in `pending` indefinitely. + +## Automations vs. workflows -- when to use which + +| Use case | Choice | +|---|---| +| "When a ticket is created, do X" | Workflow (`ticket.created`) | +| "Every 15 minutes, check all pending tickets and close > 7 days" | Automation | +| "When an agent replies, send a webhook" | Workflow (`reply.agent_reply`) | +| "Nightly: add the `stale` tag to tickets idle > 72h" | Automation | +| "When a VIP submits, wait 5 min then escalate if still open" | Workflow (`ticket.created` + `delay`) | + +Workflows are reactive and surgical. Automations are periodic and sweeping. Both can coexist on the same ticket; they read and write the same tables. From 0fe65e3f23d14ac20d7c8adc2e1a1d4f8047d069 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:41:08 -0400 Subject: [PATCH 02/10] docs(workflows): fix template variable names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous list invented snake_case variable names ({{ticket_id}}, {{reference_number}}, {{requester_name}}, {{requester_email}}) that don't exist on the NestJS reference ticket entity. The real interpolator (workflow-engine.service.ts:104) uses exactly the top-level scalar column names from the framework's schema — which on NestJS are camelCase (referenceNumber, requesterId, etc.) and on other frameworks match their local conventions. Also clarify that add_note does NOT interpolate — only insert_canned_reply does (in the NestJS executor's insertCannedReply; addNote writes body verbatim). Call out that non-scalar relationships (Tag[], Department) are skipped so readers don't expect {{tag}} or {{department}} to work. --- sections/workflows.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/sections/workflows.md b/sections/workflows.md index 6470b18..07810d6 100644 --- a/sections/workflows.md +++ b/sections/workflows.md @@ -74,14 +74,19 @@ Workflows can perform any of the following actions. Actions execute in the order ### Template variables -`insert_canned_reply` and `add_note` support simple `{{variable}}` interpolation against the ticket. Available variables include: +`insert_canned_reply` supports `{{variable}}` interpolation against the ticket. The engine flattens every top-level scalar column on the ticket row into the template context, so the variable names follow your framework's column naming convention. -- `{{subject}}`, `{{priority}}`, `{{status}}` -- `{{requester_name}}`, `{{requester_email}}` -- `{{ticket_id}}`, `{{reference_number}}` -- Any custom field slug on the ticket +For the NestJS reference (camelCase): -Unknown variables are left as literal `{{name}}` so gaps are visible in the rendered reply rather than silently disappearing. +- `{{subject}}`, `{{description}}`, `{{priority}}`, `{{channel}}` +- `{{referenceNumber}}`, `{{id}}` +- `{{requesterId}}`, `{{contactId}}`, `{{assigneeId}}`, `{{departmentId}}`, `{{statusId}}` + +Frameworks whose columns are snake_case (Laravel, Rails, Django, WordPress, Symfony, Phoenix) expose the same fields under snake_case names — e.g. `{{reference_number}}`, `{{requester_id}}`. Check your framework's ticket schema for the exact list. + +Non-scalar relationships (the loaded `Tag[]` array, nested `Department`, etc.) are skipped — reach for a workflow hook or a dedicated action if you need to interpolate relation data. + +Unknown variable names are left as literal `{{name}}` in the output so gaps are visible in the rendered reply rather than silently disappearing. ### Round-robin assignment From f223a38de197a0fceb410e98d49e074b3584b67e Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:42:58 -0400 Subject: [PATCH 03/10] docs(workflows): fix send_webhook payload + round_robin spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two factual corrections after cross-checking against the actual NestJS implementation: 1. send_webhook: I invented a bespoke payload shape with workflow_id, flattened ticket fields, snake_case keys. The real shape (from WebhookService.dispatch → JSON.stringify({ event, data, timestamp })) wraps the full ticket entity under data.ticket. Also documented the HMAC-SHA256 signature + X-Escalated-Signature header, which matches every other Escalated webhook's delivery semantics. 2. assign_round_robin: I said it accepts a list of agent ids or 'all agents in department X'. Neither is right. The actual value is a single department id; the executor finds all active + available AgentProfiles linked to that department and picks the one with the fewest open tickets (tie-break by user id for determinism). Fixed the description, the action-catalog table entry, and the example workflow's assign_round_robin: [3,7,11] line to be assign_round_robin: 7 (a department id). --- sections/workflows.md | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/sections/workflows.md b/sections/workflows.md index 07810d6..44b808f 100644 --- a/sections/workflows.md +++ b/sections/workflows.md @@ -65,7 +65,7 @@ Workflows can perform any of the following actions. Actions execute in the order | `add_tag` | Attach a tag (by slug or id); no-op if already present | | `remove_tag` | Detach a tag; no-op if not present | | `assign_agent` | Assign to a specific agent by id, writing a ticket-activity audit row | -| `assign_round_robin` | Assign to the least-loaded agent from a pool (see below) | +| `assign_round_robin` | Assign to the least-loaded active agent in a department (see below) | | `add_note` | Add an internal note visible only to agents | | `insert_canned_reply` | Add a public reply with `{{field}}` template interpolation | | `add_follower` | Add a user as a ticket follower (idempotent) | @@ -90,7 +90,7 @@ Unknown variable names are left as literal `{{name}}` in the output so gaps are ### Round-robin assignment -`assign_round_robin` accepts a list of agent ids (or "all agents in department X") and picks the **least-loaded** agent -- the one with the fewest currently-open tickets. This favors the agent most likely to be idle rather than rotating strictly. If two agents tie, the one with the lower id wins for determinism. +`assign_round_robin` takes a department id as its `value` and picks the **least-loaded active + available agent** in that department — the one with the fewest currently-open tickets assigned to them. This favors the agent most likely to be idle rather than rotating strictly. If two agents tie on open-ticket count, the one with the lower user id wins for determinism. Ineligible inputs (non-numeric or zero department id, empty eligible-agent list) log a warning and skip without assigning. ### Delayed actions @@ -114,25 +114,19 @@ The deferred-job queue retains rows after completion (status flips from `pending ## Webhook action -`send_webhook` posts a JSON payload to a webhook you've configured under `Admin -> Webhooks`. The payload is: +`send_webhook` takes a webhook id (the numeric id of a row you've configured under `Admin -> Webhooks`) and POSTs a payload to that webhook's configured URL. The HTTP body: ```json { "event": "workflow.triggered", - "workflow_id": 42, - "ticket": { - "id": 1234, - "reference_number": "TICK-1234", - "subject": "...", - "status": "open", - "priority": "high", - "assignee_id": 7, - "requester_email": "alice@example.com" - } + "data": { + "ticket": { "id": 1234, "subject": "...", "priority": "high", ... } + }, + "timestamp": "2026-04-24T14:00:00.000Z" } ``` -Delivery follows the same retry + signing rules as other Escalated webhooks. Failures do not block subsequent actions in the same workflow. +The full ticket entity is passed through as `data.ticket` — relationships present on the in-memory entity at dispatch time (tags, department) are included. Delivery follows the same HMAC-SHA256 signing + retry rules as every other Escalated webhook: the `X-Escalated-Signature` header carries `sha256=` of the payload body signed with the webhook's configured secret. Delivery failures are recorded on `WebhookDelivery` for the retry scheduler to pick up; they do not block subsequent actions in the same workflow. ## Example workflows @@ -145,7 +139,7 @@ conditions: actions: - change_priority: urgent - add_tag: vip - - assign_round_robin: [3, 7, 11] # senior agents + - assign_round_robin: 7 # "Senior support" department id - send_webhook: 9 # page PagerDuty ``` From 60ce98a32ca785d78a974e4e56b45de2bc94268a Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:45:31 -0400 Subject: [PATCH 04/10] docs(workflows): trim trigger-event list to what WorkflowListener actually handles Previously listed 14 trigger events; the WorkflowListener (at src/listeners/workflow.listener.ts) only wires five: ticket.created / ticket.updated / ticket.assigned / ticket.status_changed / reply.created. Everything else I invented (ticket.priority_changed, ticket.department_changed, ticket.tagged, ticket.reopened, reply.agent_reply, sla.warning, sla.breached, inbound.received, signup.invite) either doesn't exist in ESCALATED_EVENTS or exists but isn't bridged to the Workflow runner. Also fix the decision table: 'When an agent replies' was pointing at the nonexistent reply.agent_reply; redirect readers to use reply.created with an agent-type condition filter. --- sections/workflows.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/sections/workflows.md b/sections/workflows.md index 44b808f..16929ae 100644 --- a/sections/workflows.md +++ b/sections/workflows.md @@ -20,21 +20,14 @@ When an event fires, Escalated dispatches it to the workflow engine, which looks |---|---| | `ticket.created` | A new ticket is created (via API, inbound email, widget, or agent UI) | | `ticket.updated` | Any field on the ticket changes | -| `ticket.status_changed` | The ticket status transitions (e.g. `open -> solved`) | -| `ticket.priority_changed` | The priority changes | | `ticket.assigned` | The `assignee_id` changes | -| `ticket.department_changed` | The ticket moves to a different department | -| `ticket.tagged` | A tag is added or removed | -| `ticket.reopened` | A closed ticket receives a new reply and is reopened | +| `ticket.status_changed` | The ticket status transitions (e.g. `open -> solved`) | | `reply.created` | Any reply is added (agent or customer) | -| `reply.agent_reply` | An agent-authored reply is added | -| `sla.warning` | A ticket nears its SLA target | -| `sla.breached` | A ticket breaches its SLA target | -| `inbound.received` | The inbound-email webhook parses a message | -| `signup.invite` | A guest submission under `prompt_signup` policy should trigger a signup invite | A workflow subscribes to exactly one trigger event. For rules that should react to multiple events, define a workflow per event. +Fine-grained event subtypes (priority changed, tagged, reopened, SLA warnings, inbound received, signup invites) all fire on the Escalated event bus too, but today they are not bridged into the Workflow runner — they are consumed by the built-in email + activity log listeners instead. Conditions like "priority equals high" or "has tag vip" let you filter the five event types above to approximate per-subtype behavior. + ## Conditions Workflow conditions evaluate the ticket (and relevant context) at the moment the event fires. All conditions must match: @@ -189,7 +182,7 @@ The `delay` action and any other deferred behaviors depend on the application sc |---|---| | "When a ticket is created, do X" | Workflow (`ticket.created`) | | "Every 15 minutes, check all pending tickets and close > 7 days" | Automation | -| "When an agent replies, send a webhook" | Workflow (`reply.agent_reply`) | +| "When an agent replies, send a webhook" | Workflow (`reply.created` with a condition filtering out customer-authored replies) | | "Nightly: add the `stale` tag to tickets idle > 72h" | Automation | | "When a VIP submits, wait 5 min then escalate if still open" | Workflow (`ticket.created` + `delay`) | From 417a077a27186970258d8c81ba7109131fd25ca2 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:47:17 -0400 Subject: [PATCH 05/10] docs(workflows): rewrite conditions + examples against real shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections: 1. Conditions are { field, operator, value } triples working on any top-level scalar column of the ticket — not named operators like has_tag / subject_contains / requester_email_matches. Listed the actual 12 operators from WorkflowEngineService.applyOperator. 2. Fixed the intro line that still referenced 'has tag vip' as if it were a condition — conditions can't traverse relationships, you'd need to flatten tag state onto a ticket column first. 3. Rewrote all four example workflows as JSON matching the actual storage format (conditions as array / all / any, actions as typed objects) instead of the invented YAML shorthand. Also dropped the {{requester_name}} variable that doesn't exist and noted why {{referenceNumber}} / {{reference_number}} differ across framework ports. --- sections/workflows.md | 127 ++++++++++++++++++++++++++---------------- 1 file changed, 80 insertions(+), 47 deletions(-) diff --git a/sections/workflows.md b/sections/workflows.md index 16929ae..88eebb3 100644 --- a/sections/workflows.md +++ b/sections/workflows.md @@ -26,25 +26,37 @@ When an event fires, Escalated dispatches it to the workflow engine, which looks A workflow subscribes to exactly one trigger event. For rules that should react to multiple events, define a workflow per event. -Fine-grained event subtypes (priority changed, tagged, reopened, SLA warnings, inbound received, signup invites) all fire on the Escalated event bus too, but today they are not bridged into the Workflow runner — they are consumed by the built-in email + activity log listeners instead. Conditions like "priority equals high" or "has tag vip" let you filter the five event types above to approximate per-subtype behavior. +Fine-grained event subtypes (priority changed, tagged, reopened, SLA warnings, inbound received, signup invites) all fire on the Escalated event bus too, but today they are not bridged into the Workflow runner — they are consumed by the built-in email + activity-log listeners instead. Use the five events above combined with per-field conditions to approximate per-subtype behavior. ## Conditions -Workflow conditions evaluate the ticket (and relevant context) at the moment the event fires. All conditions must match: +A condition is a `{ field, operator, value }` triple. `field` names any top-level ticket column (framework-specific column names apply — see [Template variables](#template-variables) for the naming convention on your framework). The engine looks up `ticket[field]`, coerces to string, and applies `operator` against `value`. -- **`status`** -- ticket has a specific status slug -- **`priority`** -- ticket priority is one of `low`, `medium`, `high`, `urgent` -- **`ticket_type`** -- ticket type is `question`, `problem`, `incident`, or `task` -- **`subject_contains`** -- ticket subject includes a keyword (case-insensitive) -- **`description_contains`** -- ticket description includes a keyword -- **`assigned`** / **`unassigned`** -- ticket has (or does not have) an assignee -- **`department`** -- ticket belongs to a specific department -- **`has_tag`** / **`lacks_tag`** -- ticket has (or does not have) a specific tag -- **`requester_email_matches`** -- requester/contact email matches a pattern (glob or domain) -- **`hours_open`** -- ticket has been open for more than N hours -- **`from_channel`** -- ticket originated from a specific channel (`email`, `widget`, `api`, `chat`) +**Operators:** -Combine conditions freely. If you need OR semantics, create multiple workflows on the same trigger with different condition sets. +| Operator | Meaning | +|---|---| +| `equals` / `not_equals` | String equality | +| `contains` / `not_contains` | Substring match (case-sensitive) | +| `starts_with` / `ends_with` | String prefix / suffix | +| `greater_than` / `less_than` | Numeric comparison (value coerced via `Number()`) | +| `greater_or_equal` / `less_or_equal` | Numeric comparison | +| `is_empty` / `is_not_empty` | Trim + length check (value arg ignored) | + +**Example:** to match open tickets from VIPs, combine two conditions: + +```json +{ + "all": [ + { "field": "status", "operator": "equals", "value": "open" }, + { "field": "requesterEmail", "operator": "ends_with", "value": "@vip.example.com" } + ] +} +``` + +Conditions combine via `all` (AND) or `any` (OR); a bare array is treated as `all`. If you need disjoint OR-semantics across multiple triggers, define separate workflows on the same trigger with different condition sets. + +> **Note:** Conditions operate on whatever scalar fields the framework's ticket schema exposes. Relationship-based filters (has_tag, in_department_name, requester_email_matches_pattern) aren't built in — use a `ticket_type`-style discriminator column or write a pre-filter workflow that tags the ticket, then filter by tag downstream. ## Actions @@ -89,16 +101,20 @@ Unknown variable names are left as literal `{{name}}` in the output so gaps are The `delay` action splits a workflow run into two halves. Actions before the delay run inline. Remaining actions are persisted to a deferred-job queue with `run_at = now + N seconds` and picked up by a scheduled poller (runs once per minute by default) after the wait elapses. -Example: "When a ticket is created by a VIP, wait 5 minutes, then if the ticket is still open, add a note to ping the on-call manager." +Example: "When a ticket is created by a caller from an urgent priority, tag it, wait 5 minutes, then leave a note to page on-call." -```yaml -trigger: ticket.created -conditions: - has_tag: vip -actions: - - add_tag: needs-fast-response - - delay: 300 # 5 minutes - - add_note: "Still open after 5 min -- page on-call." +```json +{ + "trigger_event": "ticket.created", + "conditions": [ + { "field": "priority", "operator": "equals", "value": "urgent" } + ], + "actions": [ + { "type": "add_tag", "value": "needs-fast-response" }, + { "type": "delay", "value": "300" }, + { "type": "add_note", "value": "Still open after 5 min -- page on-call." } + ] +} ``` The first two actions happen immediately. If the ticket is still open 5 minutes later, the note is added; if the ticket was already closed in the meantime, the note still fires (the delay does not re-evaluate conditions -- it resumes the saved action sequence verbatim). To make conditional resume-time behavior, fan out to a separate workflow trigger. @@ -125,40 +141,57 @@ The full ticket entity is passed through as `data.ticket` — relationships pres ### Auto-tag and assign high-priority VIP tickets -```yaml -trigger: ticket.created -conditions: - requester_email_matches: "@vip.example.com" -actions: - - change_priority: urgent - - add_tag: vip - - assign_round_robin: 7 # "Senior support" department id - - send_webhook: 9 # page PagerDuty +```json +{ + "trigger_event": "ticket.created", + "conditions": { + "all": [ + { "field": "requesterEmail", "operator": "ends_with", "value": "@vip.example.com" } + ] + }, + "actions": [ + { "type": "change_priority", "value": "urgent" }, + { "type": "add_tag", "value": "vip" }, + { "type": "assign_round_robin", "value": "7" }, + { "type": "send_webhook", "value": "9" } + ] +} ``` ### Chase stale tickets -```yaml -trigger: ticket.status_changed -conditions: - status: pending -actions: - - delay: 86400 # 24 hours - - add_note: "Pending > 24h. Consider following up." +```json +{ + "trigger_event": "ticket.status_changed", + "conditions": [ + { "field": "status", "operator": "equals", "value": "pending" } + ], + "actions": [ + { "type": "delay", "value": "86400" }, + { "type": "add_note", "value": "Pending > 24h. Consider following up." } + ] +} ``` ### Triage by subject -```yaml -trigger: ticket.created -conditions: - subject_contains: refund -actions: - - set_department: 5 # Billing - - add_tag: refund-request - - insert_canned_reply: "Hi {{requester_name}}, we've received your refund request (#{{reference_number}}) and will respond within 1 business day." +```json +{ + "trigger_event": "ticket.created", + "conditions": [ + { "field": "subject", "operator": "contains", "value": "refund" } + ], + "actions": [ + { "type": "set_department", "value": "5" }, + { "type": "add_tag", "value": "refund-request" }, + { "type": "insert_canned_reply", + "value": "Hi, we've received your refund request (#{{referenceNumber}}) and will respond within 1 business day." } + ] +} ``` +The canned-reply template uses `{{referenceNumber}}` because the NestJS reference stores ticket reference as a camelCase scalar column. Frameworks with snake_case columns would use `{{reference_number}}`. There is no `{{requester_name}}` variable — contact name is a separate related row that the interpolator doesn't traverse. + ## Workflow logs Every workflow run -- match or no match, success or failure -- writes a row to the workflow log visible at `Admin -> Workflows -> Logs`. Each row records: From e11eb52e7c3b0e15ae389a58bdbce2d057173adc Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:51:26 -0400 Subject: [PATCH 06/10] docs(workflows): fix workflow-log semantics Two wrong claims: 1. 'For each action: status done/skipped/failed and any error message' is wrong. The executor catches individual action failures and log-warns them but does NOT persist per-action status. The log row only stores the entire workflow's error_message (top-level throw) if any; otherwise null. 2. 'For deferred actions: a link to the deferred-job row and its eventual outcome' is wrong. WorkflowLog has no FK to DeferredWorkflowJob; the two tables are independent. Inspect escalated_deferred_workflow_jobs directly (status + last_error) for delay audit. Also renamed the 'workflow run' phrasing to 'workflow considered' since the log row fires even when conditions don't match (matched=false). --- sections/workflows.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sections/workflows.md b/sections/workflows.md index 88eebb3..c6e7688 100644 --- a/sections/workflows.md +++ b/sections/workflows.md @@ -194,12 +194,15 @@ The canned-reply template uses `{{referenceNumber}}` because the NestJS referenc ## Workflow logs -Every workflow run -- match or no match, success or failure -- writes a row to the workflow log visible at `Admin -> Workflows -> Logs`. Each row records: +Every workflow *considered* -- match or no match -- writes a row to `escalated_workflow_logs`, surfaced in the UI at `Admin -> Workflows -> Logs`. Each row records: -- Trigger event, triggering ticket id, workflow id -- Whether conditions matched -- For each action: status (`done`, `skipped`, `failed`) and any error message -- For deferred actions: a link to the deferred-job row and its eventual outcome +- Trigger event string, triggering ticket id (FK), workflow id (FK) +- `conditions_matched` boolean — whether the workflow actually fired +- `actions_executed_raw` JSON — the actions array as stored on the workflow when it fired (not per-action status; re-read the workflow to see current config) +- `error_message` — top-level error from the executor if the whole run threw, else null. Individual action failures within the executor are log-warn and do NOT surface here +- `started_at` / `completed_at` timestamps so you can derive duration + +Deferred (`delay`) actions do not get their own log row — inspect `escalated_deferred_workflow_jobs` directly (status + last_error columns) to audit what fired when. ## Managing workflows From f8fc5e47c69385ad497b011f4e0cb67b95ce7d2e Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:58:09 -0400 Subject: [PATCH 07/10] docs(workflows): call out seconds vs minutes unit divergence for delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delay action interprets its value as seconds on some frameworks (NestJS / Spring / WordPress — the ones ported during this rollout, which chose seconds to match the NestJS reference) and minutes on others (Laravel / Rails / Django / Adonis / .NET / Go / Phoenix / Symfony — which shipped delay with the pre-existing 'minutes' convention before the rollout touched them). Documented this in the Delayed actions section and annotated both concrete-number examples (300 for 5 min, 86400 for 24h) with minutes-equivalents so readers don't accidentally schedule a 60-day wait on Laravel. --- sections/workflows.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sections/workflows.md b/sections/workflows.md index c6e7688..58122ef 100644 --- a/sections/workflows.md +++ b/sections/workflows.md @@ -99,7 +99,9 @@ Unknown variable names are left as literal `{{name}}` in the output so gaps are ### Delayed actions -The `delay` action splits a workflow run into two halves. Actions before the delay run inline. Remaining actions are persisted to a deferred-job queue with `run_at = now + N seconds` and picked up by a scheduled poller (runs once per minute by default) after the wait elapses. +The `delay` action splits a workflow run into two halves. Actions before the delay run inline. Remaining actions are persisted to a deferred-job queue with `run_at = now + N` and picked up by a scheduled poller (runs once per minute by default) after the wait elapses. + +**`N` units differ by framework.** NestJS, Spring, and WordPress interpret `delay.value` as **seconds**; Laravel, Rails, Django, Adonis, .NET, Go, Phoenix, and Symfony interpret it as **minutes** (matching the pre-existing `delayed_actions` schema those plugins had before this rollout). Check your plugin's workflow-executor source if you're unsure. Example: "When a ticket is created by a caller from an urgent priority, tag it, wait 5 minutes, then leave a note to page on-call." @@ -117,6 +119,8 @@ Example: "When a ticket is created by a caller from an urgent priority, tag it, } ``` +> `"value": "300"` is 5 minutes on the seconds-unit frameworks (NestJS / Spring / WordPress). On the minutes-unit frameworks you'd write `"value": "5"` for the same behavior. + The first two actions happen immediately. If the ticket is still open 5 minutes later, the note is added; if the ticket was already closed in the meantime, the note still fires (the delay does not re-evaluate conditions -- it resumes the saved action sequence verbatim). To make conditional resume-time behavior, fan out to a separate workflow trigger. The deferred-job queue retains rows after completion (status flips from `pending` to `done` or `failed`) so you can audit when and why each delay fired. @@ -173,6 +177,8 @@ The full ticket entity is passed through as `data.ticket` — relationships pres } ``` +> `"value": "86400"` is 24h on the seconds-unit frameworks; on minutes-unit frameworks use `"value": "1440"`. + ### Triage by subject ```json From 0eab5d055b30030a518fc414bd5da68ebd144d40 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:00:30 -0400 Subject: [PATCH 08/10] docs(workflows): example workflow can't filter by requesterEmail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the Conditions example and the top-level 'Auto-tag VIP tickets' example filtered on a 'requesterEmail' field. That field is NOT in the condition map built by WorkflowRunner.ticketToConditionMap — that map only contains top-level scalar columns, and the ticket entity's requester_email is a virtual computed-field populated lazily by TicketService.enrichTickets, which isn't called before the ticket.created event fires. Rewrote both examples to use fields that ARE in the condition map (priority, subject), and added a prominent note in the Conditions section explaining what IS and ISN'T accessible + how to work around it (denormalize onto a ticket column, or fan out via a pre-filter workflow that tags the ticket first). --- sections/workflows.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/sections/workflows.md b/sections/workflows.md index 58122ef..ffa289a 100644 --- a/sections/workflows.md +++ b/sections/workflows.md @@ -43,20 +43,20 @@ A condition is a `{ field, operator, value }` triple. `field` names any top-leve | `greater_or_equal` / `less_or_equal` | Numeric comparison | | `is_empty` / `is_not_empty` | Trim + length check (value arg ignored) | -**Example:** to match open tickets from VIPs, combine two conditions: +**Example:** to match high-priority tickets mentioning "refund" in the subject: ```json { "all": [ - { "field": "status", "operator": "equals", "value": "open" }, - { "field": "requesterEmail", "operator": "ends_with", "value": "@vip.example.com" } + { "field": "priority", "operator": "equals", "value": "urgent" }, + { "field": "subject", "operator": "contains", "value": "refund" } ] } ``` -Conditions combine via `all` (AND) or `any` (OR); a bare array is treated as `all`. If you need disjoint OR-semantics across multiple triggers, define separate workflows on the same trigger with different condition sets. +Conditions combine via `all` (AND) or `any` (OR); a bare array is treated as `all`. If you need disjoint OR-semantics, define separate workflows on the same trigger with different condition sets. -> **Note:** Conditions operate on whatever scalar fields the framework's ticket schema exposes. Relationship-based filters (has_tag, in_department_name, requester_email_matches_pattern) aren't built in — use a `ticket_type`-style discriminator column or write a pre-filter workflow that tags the ticket, then filter by tag downstream. +> **Scope of accessible fields.** The engine flattens the ticket entity into a string map of top-level scalar columns only (id, referenceNumber, subject, description, priority, channel, statusId, departmentId, requesterId, assigneeId, contactId, …). Relationship data (`tags`, joined `Department`, joined `Contact.email`) is **not** in the map, so filters like "has tag vip" or "requester email ends with @foo.com" don't work out-of-the-box. If you need those, either (a) denormalize the field you care about onto the ticket (e.g. a `customer_tier` column populated at creation) or (b) use a first-pass workflow to tag the ticket, then filter downstream workflows by the new scalar-populated state. ## Actions @@ -143,25 +143,27 @@ The full ticket entity is passed through as `data.ticket` — relationships pres ## Example workflows -### Auto-tag and assign high-priority VIP tickets +### Auto-tag and route billing tickets ```json { "trigger_event": "ticket.created", "conditions": { "all": [ - { "field": "requesterEmail", "operator": "ends_with", "value": "@vip.example.com" } + { "field": "subject", "operator": "contains", "value": "billing" } ] }, "actions": [ - { "type": "change_priority", "value": "urgent" }, - { "type": "add_tag", "value": "vip" }, + { "type": "change_priority", "value": "high" }, + { "type": "add_tag", "value": "billing" }, { "type": "assign_round_robin", "value": "7" }, { "type": "send_webhook", "value": "9" } ] } ``` +The `assign_round_robin` value `"7"` is the id of the Billing department; `"send_webhook"` value `"9"` is the id of a webhook you've configured under `Admin → Webhooks` (typically pointing at PagerDuty or similar). + ### Chase stale tickets ```json From 3def3980f7f854aa45cc60a36fbd59ea76eaa6ee Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:01:50 -0400 Subject: [PATCH 09/10] docs(workflows): fix webhook signature header format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claimed the X-Escalated-Signature header carries 'sha256='. The actual value (WebhookService.sign at line 175-177) is just the hex digest — no prefix. Also added the sibling X-Escalated-Event header to the docs since receivers need to know both exist. Spelled out the verification recipe (recompute hex(hmac_sha256(secret, body)) + timing-safe equality) so integrators don't have to reverse-engineer it. --- sections/workflows.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sections/workflows.md b/sections/workflows.md index ffa289a..acc6208 100644 --- a/sections/workflows.md +++ b/sections/workflows.md @@ -139,7 +139,12 @@ The deferred-job queue retains rows after completion (status flips from `pending } ``` -The full ticket entity is passed through as `data.ticket` — relationships present on the in-memory entity at dispatch time (tags, department) are included. Delivery follows the same HMAC-SHA256 signing + retry rules as every other Escalated webhook: the `X-Escalated-Signature` header carries `sha256=` of the payload body signed with the webhook's configured secret. Delivery failures are recorded on `WebhookDelivery` for the retry scheduler to pick up; they do not block subsequent actions in the same workflow. +The full ticket entity is passed through as `data.ticket` — relationships present on the in-memory entity at dispatch time (tags, department) are included. Delivery follows the same HMAC-SHA256 signing + retry rules as every other Escalated webhook. Each request carries two headers: + +- `X-Escalated-Signature`: the hex-encoded HMAC-SHA256 of the raw payload body, using the webhook's configured secret. Verify on your side by recomputing `hex(hmac_sha256(secret, body))` and comparing with a timing-safe equality check. +- `X-Escalated-Event`: the event name (`workflow.triggered` for this action; other events use their own names). + +Delivery failures are recorded on `WebhookDelivery` for the retry scheduler to pick up; they do not block subsequent actions in the same workflow. ## Example workflows From 31f39a18a790a539d9c36e82104e21a6cf59d8ab Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:08:30 -0400 Subject: [PATCH 10/10] =?UTF-8?q?docs(workflows):=20decision=20table=20?= =?UTF-8?q?=E2=80=94=20can't=20filter=20reply.created=20by=20author?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'When an agent replies' row said to use reply.created with a condition filtering out customer-authored replies. That doesn't work: WorkflowListener.onReplyCreated dispatches the TICKET (not the reply) to the runner, so reply-level fields like author_type are not in the condition map. Rewrote the row to say the workflow fires on *any* reply and filtering must happen on the webhook receiver. Also replaced the VIP delay row with a 'high-priority delay' row matching what the earlier conditions example can actually express. --- sections/workflows.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sections/workflows.md b/sections/workflows.md index acc6208..97527bb 100644 --- a/sections/workflows.md +++ b/sections/workflows.md @@ -231,8 +231,8 @@ The `delay` action and any other deferred behaviors depend on the application sc |---|---| | "When a ticket is created, do X" | Workflow (`ticket.created`) | | "Every 15 minutes, check all pending tickets and close > 7 days" | Automation | -| "When an agent replies, send a webhook" | Workflow (`reply.created` with a condition filtering out customer-authored replies) | +| "When *any* reply is added, send a webhook" | Workflow (`reply.created`) — note the event fires with the TICKET as payload, not the reply, so you can't filter by `author_type` in conditions; the webhook receives the ticket and your downstream handler decides | | "Nightly: add the `stale` tag to tickets idle > 72h" | Automation | -| "When a VIP submits, wait 5 min then escalate if still open" | Workflow (`ticket.created` + `delay`) | +| "When a high-priority ticket is created, wait 5 min then leave an escalation note" | Workflow (`ticket.created` + priority condition + `delay`) | Workflows are reactive and surgical. Automations are periodic and sweeping. Both can coexist on the same ticket; they read and write the same tables.