> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Ticket-automation rules — author, dry-run, and roll out

> Walk the ticket-automation rule builder end to end — the trigger → conditions → actions mental model, the grammar catalog the builder reads at load time, a worked idle-ticket escalation rule, how the dry-run preview endpoints behave, and a rollout checklist.

# Ticket-automation rules — author, dry-run, and roll out

Ticket automation turns the triage questions every agent used to answer by hand — *is this billing, is it stale, who owns it* — into a rule you author once and the queue applies forever. This guide walks one rule from the grammar catalog all the way through a dry-run to a rollout checklist, and along the way tells you how the builder stays in sync with the backend's vocabulary instead of hard-coding it.

Everything here is tenant-owned configuration. Authoring, dry-running, and managing rules requires an owner or admin role; reading the grammar catalog is open to any authenticated operator. Nothing a rule does ever reaches the customer's phone — the `notify` action is restricted to Slack, in-app, and webhook destinations by the schema itself, so automation can never become an outbound-messaging path.

## 1. Mental model — one trigger, some conditions, a few actions

A rule subscribes to exactly one trigger, filters with conditions, and runs a handful of actions when they hold:

| Stage          | Vocabulary                                                                                                                                                                                                                                      | Purpose                                                                                                                            |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| **Trigger**    | `ticket_created`, `ticket_tagged`, `ticket_idle`, `ticket_status_changed`, `ticket_priority_changed`                                                                                                                                            | The lifecycle event the rule listens to — one per rule.                                                                            |
| **Conditions** | Fields: `priority`, `status`, `type`, `source`, `tags`, `assignee`, `subject`, `requester_email`, `idle_minutes`, `custom_field`. Operators: `eq`, `neq`, `in`, `not_in`, `contains`, `not_contains`, `gte`, `lte`, `is_empty`, `is_not_empty`. | Restrict the trigger: `tags contains "billing-issue"`, `status in ["open","pending"]`. A rule with no conditions is unconditional. |
| **Actions**    | `assign`, `unassign`, `add_tag`, `remove_tag`, `set_priority`, `set_status`, `set_type`, `set_custom_field`, `escalate`, `notify`                                                                                                               | What runs when the conditions hold — reassign, retag, move a field, escalate, or notify a Slack / in-app / webhook destination.    |

Two knobs control how a rule composes with its neighbors. `match` is `all` (AND) or `any` (OR) over the condition set. `priority` orders evaluation — lower runs first — and a matching rule with `stop_processing: true` ends that event's pass, so a decisive first-match rule is final.

The [product page](/inbox/ticket-automation) explains the per-action behaviour (assign skips an agent already at their channel cap, tags land with canonical casing, priority changes re-scale SLA due windows, escalation combines priority + assignee in one action). The rest of this guide is the doing.

## 2. Where the builder lives

**Inbox → Settings → Ticket automation**, RoleGuard'd to owner/admin to match the backend gate on the test and rules endpoints. The page has two halves — the rule editor on top (trigger, conditions, actions) and a dry-run panel below (sample ticket + event) — and is deliberately catalog-driven: the trigger selector, the per-field operator options, and the value dropdowns all render from the backend's catalog, not from strings bundled into the frontend.

## 3. The API surface the builder consumes

Two endpoints power the builder, and a rules CRUD set persists what passes the test. The full request/response schemas live in the [Inbox API reference](/api-reference/inbox); what a guide needs is the contract:

| Endpoint                                                      | Role                       | Purpose                                                                                                     |
| ------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/inbox/ticket-automation/catalog`                 | any authenticated operator | Static grammar — triggers, per-field operators, action types, closed priority/status/type value lists.      |
| `POST /api/v1/inbox/ticket-automation/test`                   | owner/admin                | Dry-run one inline rule against a sample ticket + event. Returns what would fire without mutating anything. |
| `GET/POST/PATCH/DELETE /api/v1/inbox/ticket-automation/rules` | owner/admin                | Persisted-rule CRUD — list, create, replace, delete.                                                        |

The catalog is the answer to the enum-drift problem, and it is the pattern this guide recommends you copy: the page requests the grammar once, renders form options from it, and therefore cannot hard-code a stale list the backend later changes.

### 3.1 Persisted-rule execution, in milestones

The persisted side is deliberately split into milestones, and the workspace page header names the split: the first milestone owns the builder + preview surface, the follow-up owns durable storage and the execution path. Respect that ordering when you roll rules out — the dry-run contract is stable, so author and validate first, and treat persistence as the thing you enable only once the rule's preview consistently matches your intent.

Also note which triggers the current synchronous path fires: `ticket_created`, `ticket_status_changed`, and `ticket_priority_changed` emit from the ticket mutation service; `ticket_tagged` and `ticket_idle` enter through the same rule grammar but are scheduled-event arms — the `ticket_idle` arm is the one a future sweep closes. The rule mechanics below work identically either way, because the engine is the same pure core the preview endpoint and the executor share.

## 4. Worked end-to-end — the billing-escalation rule

The target: **when a ticket tagged `billing-issue` goes idle for over two hours, retag it as `escalated` and notify the supervisor-code** over Slack. We author it against the catalog, dry-run it against a sample ticket, then save it as a persisted rule.

```yaml theme={null}
# The rule your builder eventually saves — plain YAML-ish form you can also
# paste into the POST /rules body verbatim as JSON (the shapes match 1:1).
name: "Billing idle → escalate + notify"
trigger: ticket_idle
priority: 20                 # lower runs first — keep this above generic triage rules
match: all                    # AND on the condition set
conditions:
  - field: tags
    operator: contains
    value: "billing-issue"
  - field: idle_minutes
    operator: gte
    value: 120
actions:
  - type: add_tag
    tag: "escalated"
  - type: notify
    channel: slack
    target: "#billing-supervisor"
stop_processing: false
```

### 4.1 Authoring against the catalog

The builder's first move for every rule is the same five-step checklist:

1. **Pick the trigger** — `ticket_idle`. The picker renders from `catalog.triggers` returned by `GET /ticket-automation/catalog`, so it names exactly the five lifecycle events above.
2. **Add the tag condition** — field `tags`, operator `contains`, value `"billing-issue"`.
3. **Add the time gate** — field `idle_minutes`, operator `gte`, value `120`. The catalog reports `idle_minutes` with only `gte`/`lte` in its operator list, which is how the builder narrows the picker for this field instead of offering every operator everywhere.
4. **Pick `match: all`** — both conditions must hold. Distinct from `any`, which needs only one to hold and would fire the escalation on any tag-less idle ticket.
5. **Wire the actions** — `add_tag` with `tag: "escalated"`, then `notify` with `channel: "slack"` and `target: "#billing-supervisor"`. The dropdowns render from `catalog.action_types` plus the closed `priorities`/`statuses`/`types` lists the catalog also returns.

Up to ten actions per rule; keep this rule to two so the audit line stays readable.

### 4.2 Dry-run the rule

Before saving anything, dry-run the inline rule against a sample ticket whose last-touch timestamp we fake with `idle_minutes: 150`:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/inbox/ticket-automation/test" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "rule": {
      "name": "Billing idle → escalate",
      "trigger": "ticket_idle",
      "match": "all",
      "conditions": [
        { "field": "tags", "operator": "contains", "value": "billing-issue" },
        { "field": "idle_minutes", "operator": "gte", "value": 120 }
      ],
      "actions": [
        { "type": "add_tag", "tag": "escalated" },
        { "type": "notify", "channel": "slack", "target": "#billing-supervisor" }
      ]
    },
    "trigger": "ticket_idle",
    "sample_ticket": {
      "priority": "normal",
      "status": "open",
      "type": "question",
      "source": "manual",
      "tags": ["billing-issue"],
      "assignee_user_id": null,
      "subject": "Refund request",
      "requester_email": "customer@example.com",
      "custom_fields": {},
      "idle_minutes": 150
    }
  }'
```

A trimmed response:

```json theme={null}
{
  "matched": true,
  "trigger_applies": true,
  "actions": [
    { "type": "add_tag", "tag": "escalated" },
    { "type": "notify", "channel": "slack", "target": "#billing-supervisor" }
  ],
  "effect": {
    "patch": { "tags": ["billing-issue", "escalated"] },
    "notifications": [
      { "channel": "slack", "target": "#billing-supervisor" }
    ],
    "effect_count": 2
  }
}
```

Three fields to read off every preview:

* `matched` — did the rule's conditions hold on that sample.
* `actions` — the fire list in evaluation order.
* `effect` — the resulting ticket patch plus the notifications the actions would emit. The `patch` is folded so only fields that actually change show up — a no-op `add_tag` on an already-tagged ticket contributes nothing.

Flip the sample's `idle_minutes` to `60` and the call returns `matched: false` with an empty actions and effect — the dry-run is symmetric, so use it to probe both sides of the gate.

### 4.3 Save the rule

Once the preview says yes, persist the rule with `POST /api/v1/inbox/ticket-automation/rules` — the body is exactly the `rule` object above, unchanged:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/inbox/ticket-automation/rules" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Billing idle → escalate + notify",
    "trigger": "ticket_idle",
    "priority": 20,
    "match": "all",
    "conditions": [
      { "field": "tags", "operator": "contains", "value": "billing-issue" },
      { "field": "idle_minutes", "operator": "gte", "value": 120 }
    ],
    "actions": [
      { "type": "add_tag", "tag": "escalated" },
      { "type": "notify", "channel": "slack", "target": "#billing-supervisor" }
    ],
    "stop_processing": false
  }'
```

`stop_processing: false` is the polite default — leave evaluation open so the generic triage rules after this one still run. Flip it to `true` only when a rule's first-match should be authoritative (a VIP fast-track, for example).

## 5. Reading catalog enums — the no-hardcoding pattern

The catalog endpoint exists so the builder does not need its own copy of the backend's vocabulary. The mental model:

* **Fields drive operator pickers.** A field's `operators` array is the closed set for that field — `idle_minutes` lists only `gte`/`lte`, the enum fields list `eq` through `is_not_empty` without `contains`, and so on. Render the picker from the field record, not from the global operator list.
* **Closed value lists ride with the catalog.** `priorities`, `statuses`, `types` ship as arrays on the top level, and the per-field records repeat the same closed lists (so a `priority` condition can render its value dropdown straight from its `values` entry).
* **Triggers are the picker for the rule's binding event.** Rendered from `catalog.triggers`.
* **Action chips render from `catalog.action_types`.** The notify action's channel dropdown is the schema-narrowed `slack | in_app | webhook` triple, and that narrowing is the compliance gate — automation never becomes an outbound-messaging path.

A builder that hard-coded these would drift the first time the backend added a trigger or renamed an operator. Fetch the catalog at page load, cache it for the session (it is static grammar, not live state), and render every picker from it — that is how the page can truthfully say "the catalog keeps it in sync with the BE."

## 6. Rollout checklist — author to live

1. **Start from the catalog.** Load `GET /ticket-automation/catalog` once per session; your pickers should match its grammar without any manual enum list.
2. **Draft the rule.** One trigger, conditions on the fields that actually distinguish the tickets you care about, and at most a few narrowly-scoped actions.
3. **Dry-run the positive case.** Send a sample ticket that should match to `POST /ticket-automation/test`; confirm `matched: true` and the actions and effect you intend.
4. **Dry-run the negative case(s).** Flip one input — drop the tag, lower the idle minutes — and confirm `matched: false`. If the negative case matches, tighten conditions before persisting anything.
5. **Save the rule.** `POST /ticket-automation/rules`. Give the rule a verb-first name ("Billing idle → escalate + notify") so the timeline entry and the audit row read plainly.
6. **Test the persisted path.** Fire a real ticket-create / status-change / priority-change event carrying the trigger your rule subscribes to, then read the ticket timeline: the applied rule shows up as a system-authored note plus the audit row.
7. **Iterate conservatively.** `PATCH /ticket-automation/rules/:id` with the same body shape; dry-run the revision before you replace the live one. When a rule is genuinely done, `DELETE` removes it — there is no soft-disable path beyond the `enabled: false` flag.
8. **Treat execution as a milestone.** The persist + execution half of the split is announced in the page header as the follow-up milestone; until it lands on your workspace, the builder + dry-run contract above is the shipped surface and the rules you save are validated but not yet firing.

Expect the second milestone to light up `ticket_idle` schedule-driven runs — the synchronous triggers (`ticket_created`, `ticket_status_changed`, `ticket_priority_changed`) already fire; the idle and tag arms are grammar-complete where the executor is trigger-agnostic.

## See also

* [Ticket automation — event-driven ticket rules](/inbox/ticket-automation) — the product page for the same engine: action behaviour, delivery guarantees, and how automation composes with SLA escalation policies and reply approvals.
* [Inbox tickets workflow](/guides/inbox-tickets-workflow) — tickets, the queue SLA clocks they pick up at open time, and the `ticket_created` trigger they raise.
* [Inbox SLA timers](/guides/inbox-sla-timers) — the conversation-level clocks whose due windows a priority-changing rule re-scales; a nice pairing for a `set_priority` automation.
* [Webhook events](/webhooks/events) — subscribe to the `ticket.automation_applied` event the executor emits on every applied rule.
* [Inbox API reference](/api-reference/inbox) — full request/response schemas for the endpoints above (catalog, test, rules CRUD).
