---
title: Automation rules
description: Store a trigger, conditions and actions against a board over the API. The vocabulary, the limits, and why a rule is a standing grant of its actor's permissions.
section: Agents and API
order: 7
---

# Automation rules

An automation rule is three things recorded against one board: a **trigger** —
something that happens to a ticket — a set of **conditions** that narrow which
occurrences count, and a list of **actions** to take. It is the same shape
Trello's Butler, Jira's automation and ClickUp's rules all settled on, and it is
managed entirely over the API described here.

## How a rule runs

A run is queued the moment the change that triggers it happens, in the **same
transaction** as the change itself — so a rule fires if and only if the write
really landed, and a write that rolls back queues nothing. A worker picks the
queue up **every two seconds**, so the board reacts while somebody is still
looking at it.

Conditions are evaluated by that worker rather than at the moment of the event,
against the ticket as it stands when the actions are about to be applied.

Actions are applied through the same code a person's request goes through, which
is the decision worth knowing about: an automated move produces the same
validation, the same activity history, the same webhooks and the same
notifications as a human one. There is no second path that can drift from it.

Two consequences of that arrangement:

- **A run is never retried.** If an action fails, the run stops and is recorded
  as failed, with how many actions had already been applied. Replaying it would
  re-apply the ones that succeeded.
- **A rule's eligibility is re-checked when it runs**, not trusted from when it
  was queued. See [the standing grant](#a-rule-is-a-standing-grant-not-an-action).

**Every run is recorded**, including the ones where the conditions did not
match — which is what makes "why didn't my rule fire?" answerable at all. Read
them back with [the run history](#run-history).

Automations are the same events as [outbound
webhooks](/docs/agents-and-api/webhooks) with the work moved inside Laver
instead of going out to your own service. The trigger names here are the webhook
event names, deliberately, so neither concept needs a second vocabulary.

## The endpoints

| Method   | Path                                          | Who                     |
| -------- | --------------------------------------------- | ----------------------- |
| `GET`    | `/boards/:board_uuid/automations`             | Anyone who can open the board |
| `GET`    | `/boards/:board_uuid/automations/:rule_uuid`  | Anyone who can open the board |
| `POST`   | `/boards/:board_uuid/automations`             | Workspace owner or admin |
| `PATCH`  | `/boards/:board_uuid/automations/:rule_uuid`  | Workspace owner or admin |
| `DELETE` | `/boards/:board_uuid/automations/:rule_uuid`  | Workspace owner or admin |

All five accept a session token **or** an API key. Reading is deliberately wider
than writing — see [the standing grant](#a-rule-is-a-standing-grant-not-an-action)
below for why writing is not.

### Create one

```bash
curl -X POST https://api.laver.app/boards/$BOARD/automations \
  -H "Authorization: Bearer $LAVER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Triage inbound bugs",
    "trigger_type": "ticket.created",
    "conditions": [
      { "field": "label", "operator": "has", "value": "<label uuid>" }
    ],
    "actions": [
      { "type": "assign_user", "user_uuid": "…" },
      { "type": "set_priority", "priority": "high" }
    ]
  }'
```

```json
{
    "automation": {
        "uuid": "11111111-2222-4333-8444-555555555555",
        "workspace_uuid": "…",
        "board_uuid": "…",
        "name": "Triage inbound bugs",
        "enabled": true,
        "trigger_type": "ticket.created",
        "trigger_config": {},
        "conditions": [
            { "field": "label", "operator": "has", "value": "<label uuid>" }
        ],
        "actions": [
            { "type": "assign_user", "user_uuid": "…" },
            { "type": "set_priority", "priority": "high" }
        ],
        "run_as_user_uuid": "…",
        "created_by_user_uuid": "…",
        "version": 1,
        "created_at": "2026-08-07T09:20:00.512Z",
        "updated_at": "2026-08-07T09:20:00.512Z"
    }
}
```

| Field              | Required | Notes                                                        |
| ------------------ | -------- | ------------------------------------------------------------ |
| `name`             | yes      | Up to 200 characters, and not only whitespace                |
| `trigger_type`     | yes      | One of the triggers below; anything else is a `400`          |
| `actions`          | yes      | One to twenty, each an object with a `type` from the list below |
| `conditions`       | no       | Up to twenty, from the vocabulary below. Defaults to `[]`, meaning every occurrence of the trigger |
| `trigger_config`   | no       | Free-form object, reserved for per-trigger settings. Defaults to `{}` |
| `enabled`          | no       | Defaults to **true** — a rule arrives switched on            |
| `run_as_user_uuid` | no       | Defaults to you. Read the security section before setting it to anybody else |

## Triggers

| Trigger                    | The occurrence                         |
| -------------------------- | -------------------------------------- |
| `ticket.created`           | A ticket is created                    |
| `ticket.updated`           | A ticket's fields change               |
| `ticket.moved`             | A ticket changes column or board       |
| `ticket.assigned`          | Someone is added to a ticket           |
| `ticket.unassigned`        | Someone is removed from a ticket       |
| `ticket.label_added`       | A label is put on a ticket             |
| `ticket.label_removed`     | A label is taken off a ticket          |
| `ticket.priority_changed`  | A ticket's priority changes            |
| `ticket.archived`          | A ticket is deleted, singly or in bulk |
| `comment.created`          | A comment is posted                    |
| `schedule`                 | A time of day, daily, weekly or monthly |
| `ticket.due`               | A ticket's due date arrives, approaches or passes |

Every one of the event triggers is something the ticket event stream actually
publishes, which is the invariant worth knowing: a trigger nothing emits would
be a rule that could never fire. The last two are not events at all — see
[Triggers that run on a clock](#triggers-that-run-on-a-clock).

### Which tickets these fire for

A trigger is about an occurrence, and not every way a ticket can come into
existence counts as one. The rule is whether tickets arrive **one at a time as
work happens**, or in a **bulk load you are watching a progress bar for**.

| How the ticket got there            | Fires triggers |
| ----------------------------------- | -------------- |
| Created in the app or over the API  | Yes            |
| Created by a recurring ticket       | Yes            |
| Carried over by a sprint rollover   | Yes — `ticket.moved` |
| Created by copying a board          | **No**         |
| Created by a Trello / Jira / ClickUp / Planner import | **No** |

The two exclusions are deliberate. Importing two thousand cards would otherwise
queue two thousand automation runs and two thousand webhook deliveries against
tickets nobody has looked at yet, and copying a board would re-run your triage
over a duplicate of it.

Two details worth knowing rather than discovering:

- A recurring ticket's occurrences fire `ticket.created`; the template ticket the
  series was defined on does not fire again when an occurrence is generated.
- A rollover's `ticket.moved` events name the destination board, so a rule
  scoped to the board a ticket moved *away from* will not see them.

A `trigger_type` outside that list is refused at write time with a `400` naming
the value and listing the alternatives. That is the point of enumerating it: a
trigger misspelled as **ticket.moevd** would otherwise save happily, look
exactly like a working rule, and never match anything — and the only symptom of
a rule that never matches is work not happening, which is invisible by
construction.

**The webhook event list is longer than this one.** Webhooks also publish
`ticket.restored`, `ticket.duplicated`, `ticket.title_changed`,
`ticket.due_date_changed` and `wiki_page.updated`. Those are real events and
none of them is a trigger — they were left out of the first set deliberately
rather than missed. Where the names do overlap they mean the same occurrence,
so the [event table](/docs/agents-and-api/webhooks) is the description of both.

Not a trigger, and for a reason rather than an oversight: there are **no manual
trigger buttons**. They need a UI affordance that does not exist, and
enumerating one would let you save a rule that could not possibly run.

## Triggers that run on a clock

`schedule` and `ticket.due` are the two triggers no action of yours produces.
Nothing happens at the moment Monday 09:00 arrives, so they are found by a sweep
instead of by an event. Both **require** a `trigger_config`; the others ignore
it.

**These two are API-only for now.** The rule builder in the web app writes
rules as a sentence and has nowhere to put an interval or a threshold, so it
offers the event triggers only. A schedule or due-date rule is created through
the API — it then appears in the list like any other rule, and can be disabled
and deleted there.

### `schedule`

```json
{ "trigger_type": "schedule", "trigger_config": { "interval": "weekly", "at": "09:00" } }
```

`interval` is `daily`, `weekly` or `monthly`. `at` is **24-hour UTC**, as
`"HH:MM"`.

The rule anchors on the day you create it: a weekly rule created on a Tuesday
fires on Tuesdays, a monthly one created on the 9th fires on the 9th. Monthly
uses real calendar arithmetic, so a rule anchored on the 31st fires on the 30th
in a 30-day month rather than skipping that month.

A schedule rule **fans out over the board**: when it fires, it considers every
live ticket, and the rule's conditions decide which of them the actions apply
to. A schedule rule with no conditions acts on every ticket on the board, which
is very rarely what anybody means — put conditions on it.

The rule carries `next_run_at`, which is when it will next fire. Reading it is
the only way to know; it moves when you change the interval or the time, and
deliberately **does not move when you disable the rule**. That is what makes a
paused rule resume properly: switch a daily rule off for a fortnight and it
fires **once** when you switch it back on, not fourteen times.

`at` is UTC and not your local time. A local-time schedule needs a timezone and
a policy for the clock changes where an hour happens twice or not at all, and
guessing at either would move somebody's rule by an hour twice a year.

### `ticket.due`

```json
{ "trigger_type": "ticket.due", "trigger_config": { "when": "after", "days": 3 } }
```

| `when`    | Fires                                        | `days`      |
| --------- | -------------------------------------------- | ----------- |
| `arrives` | As the due day begins                        | not allowed |
| `before`  | `days` days before the due day begins        | 1 to 365    |
| `after`   | `days` days after the due day begins         | 1 to 365    |

A due date is a **calendar day**, not an instant, so the moment a rule fires is
defined rather than read off the ticket: **midnight UTC at the start of the due
day**, shifted by `days`. A ticket due on the 10th with `after: 1` fires at
00:00 on the 11th, which is the first moment it is genuinely late.

`arrives` takes no `days`. Sending one is a `400` rather than being ignored,
because `{ "when": "arrives", "days": 3 }` reads exactly like a rule that fires
three days out.

Each ticket fires **once per threshold crossing**. Change a ticket's due date
and it is a new threshold, so the rule fires again for the new one — and moving
it back does not fire it a second time for a threshold already recorded.

#### A due-date rule is not retroactive

**A rule only fires for thresholds crossed after the rule was created.** Creating
"comment when a ticket is 3 days overdue" on a board with 400 already-overdue
tickets does nothing at all, rather than posting 400 comments in the next two
seconds.

This is Trello's documented behaviour and it is copied deliberately. It also
means a rule you create today will not catch up on work that was already late:
if you want that, the tickets have to cross the threshold again, which in
practice means giving them a new due date.

Editing a rule does not reset this — it keeps its original creation time, so
widening an old rule's threshold does not reach backwards either.

## Actions

| Action            | Does                          |
| ----------------- | ----------------------------- |
| `move_to_status`  | Moves the ticket to a column  |
| `assign_user`     | Adds an assignee              |
| `unassign_user`   | Removes an assignee           |
| `set_priority`    | Sets the priority             |
| `add_label`       | Puts a label on               |
| `remove_label`    | Takes a label off             |
| `set_due_date`    | Sets the due date             |
| `add_comment`     | Comments on the ticket        |
| `archive_ticket`  | Sends it to the trash         |
| `call_webhook`    | Posts the ticket to a URL     |

`call_webhook` is the one action that leaves Laver, and it carries a caveat the
others do not. The destination is **resolved** and checked against private,
internal, loopback and link-local addresses **when the rule runs**, not when it
is saved — a hostname that resolved somewhere harmless at save time can be
repointed later, so a save-time check is one that expires. It is the same check
[outbound webhooks](/docs/agents-and-api/webhooks) are held to, including the
part where a **redirect is refused rather than followed**: a receiver answering
`302` cannot forward the request to a host the check never saw.

It is also outside the loop guards. If the receiver writes back to the ticket
through the API, that arrives as a fresh request with no memory of the rule that
caused it, so the depth counter starts again at zero. The circuit breaker still
catches it — a rule firing more than twenty times on one ticket inside a minute
is switched off — but it is the only guard that does.

Each entry in `actions` must be an object carrying a `type` from that list. A
rule needs **at least one** action and may have **at most twenty** — the same
per-run bound Butler applies, and a bound on how much work a single event can
fan out into.

Every action except `call_webhook` is a write the API can already perform as a
user, which is the test for whether an action belongs in the list at all. Its
`url` must be `http://` or `https://`, and it is held to the same private-network
refusal [outbound webhooks](/docs/agents-and-api/webhooks) are.

**Nothing creates or duplicates a ticket**, and that is a loop-prevention
decision rather than an oversight. `ticket.created` is a trigger, so an action
that creates a ticket closes that circle in a single step — a rule that feeds
itself as fast as the worker can run it. The [loop
guards](#loops-and-what-stops-them) would contain it, but containing a loop and
not shipping one are different standards.

## Conditions

Conditions narrow which occurrences of the trigger count. Each one is an object
with a `field`, an `operator` and usually a `value`:

```json
{ "field": "priority", "operator": "is", "value": "high" }
```

| Field       | Operators                              | `value`          |
| ----------- | -------------------------------------- | ---------------- |
| `status`    | `is`, `is_not`                         | A status uuid    |
| `priority`  | `is`, `is_not`                         | `none`, `low`, `medium`, `high`, `urgent` |
| `label`     | `has`, `has_not`                       | A label uuid     |
| `assignee`  | `has`, `has_not`, `is_set`, `is_not_set` | A user uuid    |
| `due_date`  | `is_set`, `is_not_set`                 | —                |
| `title`     | `contains`, `not_contains`             | Text             |

`is_set` and `is_not_set` ask about presence and take **no** `value` — sending
one is a `400` rather than a rule that quietly ignores it. Every other operator
requires a value.

The list is a **flat AND**: every condition must hold. There is no `OR`, no
nesting, and no query language. An empty list — the default — matches
everything, so the rule fires on its trigger alone.

The whole vocabulary is validated when the rule is written, so a field or
operator that does not exist is refused with the alternatives in the message.
Uuids are checked as *shapes* and not looked up, deliberately: whether a status
still exists is a question about the board at the moment a rule runs, not when
it was saved, so deleting a column does not invalidate a rule — it just stops
matching.

## Limits

| Limit                    | Value                                  |
| ------------------------ | -------------------------------------- |
| Rules per board          | **2** on Free, unlimited on the paid plan |
| Actions per rule         | 20                                     |
| Conditions per rule      | 20                                     |
| Rule name                | 200 characters                         |

Exceeding the plan limit is a `402` naming the plan and the current count,
raised only after the rule itself has been validated — so a malformed rule is
told what is wrong with it rather than being sold an upgrade for it.

Every rule names a board. Workspace-wide rules are not offered.

## A rule is a standing grant, not an action

Read this before you create a rule with an API key.

Every rule carries a **`run_as_user_uuid`** — the person it acts as. It defaults
to whoever created the rule, which for an API key means **the user that key acts
as**. So a rule created through a key does not run as "the key" or as "the
integration". It runs as a person, with that person's permissions, **every time
it is triggered, for as long as the rule exists**.

That is a different kind of thing from every other call in this API. Every other
write happens once, when you make it. A rule is a delegation of the actor's
permissions to *anybody who can cause the trigger to fire* — and the resulting
history is attributed to the actor, so the audit trail will say that person did
it.

Three consequences follow, and none of them are obvious from the endpoint:

- **Revoking the API key does not stop the rule.** The rule records a user, not
  a key. Deleting the key that created it changes nothing about what it will do.
  Disable the rule (`PATCH` with `"enabled": false`) or delete it.
- **The permissions are re-checked on every run**, not trusted from when the
  rule was written or from when the run was queued. If the actor has left the
  workspace, been deactivated, been demoted to a role that cannot write, or lost
  access to the board, the run is recorded as skipped with that reason and no
  action is taken. A rule cannot keep acting with the reach of somebody who has
  gone.
- **It still stops silently, and only in the log.** Removing someone from the
  workspace does not delete or disable the rules that name them — they stay,
  fail their eligibility check every time they fire, and do nothing. Since
  nothing exposes the run log yet, that is currently invisible. Offboarding is
  the moment to list each board's rules and delete the ones that acted as the
  person leaving.

This is also why **writing** a rule is restricted to workspace **owners and
admins**, which is narrower than the roles that may otherwise write on a board.
A member who can write on one board could otherwise build a rule that runs as
somebody with more reach, wire it to a trigger they can fire at will, and have
it perform actions they could not perform themselves. Reading rules only needs
board access; a `viewer`, `commenter` or `guest` is refused any write before any
of this is reached.

Setting `run_as_user_uuid` to somebody else is allowed for an admin, and is
checked: the actor must be an active member of the workspace who can write and
who can see the board. Naming somebody who cannot open the board is a `400`, not
a rule that silently fails every time it runs.

### What a rule's writes look like afterwards

Because a rule acts as a person, its writes carry that person's name. They are
also marked, so nobody has to take the name at face value:

- **Ticket history.** Every `task_event` an automation causes carries
  `metadata.automation`, the uuid of the rule that caused it, and the timeline
  renders it as `(automation)` after the sentence. The actor stays on the row —
  the write really was made with their permissions — but it is no longer
  indistinguishable from them doing it by hand.
- **Comments.** A comment a rule posts has `source_type: "automation"` and
  `source_id` set to the rule uuid, and shows an *Automation* badge next to the
  author. Its author is still the `run_as` user, which is what makes an
  `@mention` in it notify properly.
- **Webhooks.** The same `automation` field appears in the outbound `ticket.*`
  payload, so a subscriber can tell an automated change from a human one.
- **The audit log.** Creating, editing and deleting a rule each write an
  `automation_rule.created` / `.updated` / `.deleted` entry, recording who did it
  and — the point — the `run_as_user_uuid` it was pointed at, along with the
  rule's full definition.

The last one is what survives deletion. Deleting a rule still deletes its run
history (see below), but `automation_rule.deleted` keeps the whole definition
and a `runs_deleted` count, and its `target_uuid` is the same uuid the surviving
ticket events carry — so a marked event on a ticket can still be resolved to
what the rule was and who created it long after the rule itself is gone.

## Run history

```bash
curl "https://api.laver.app/boards/$BOARD/automations/$RULE/runs?limit=50" \
  -H "Authorization: Bearer $LAVER_API_KEY"
```

One row per time the rule was **considered**, newest first — not only per time
it did something. Each carries the ticket, the `status`, how many actions were
applied out of how many, any `error`, and when it was enqueued, started and
finished.

| `status`         | Means                                                  |
| ---------------- | ------------------------------------------------------ |
| `pending`        | Queued, not yet picked up                              |
| `running`        | Being applied now                                      |
| `success`        | Every action applied                                   |
| `no_match`       | The conditions said no, so the rule did nothing        |
| `failed`         | An action threw — see `error` and `actions_done`       |
| `depth_exceeded` | Refused by the loop guard                              |
| `skipped`        | The rule became ineligible between queueing and running |

`no_match` runs are **excluded by default**; pass `?include_no_match=true` for
them. That is the right way round for both readings: a rule with conditions
produces far more non-matches than matches, so they would bury the runs that did
something — but they are the whole answer when a rule is not firing.

Reading the history needs board access, not admin. Being able to see why the
board did something to your ticket is not the same power as creating a rule.

## Loops, and what stops them

A rule's actions are ordinary writes, so they emit ordinary events, which can
trigger other rules. That is deliberate — "move to Done, take the In Progress
label off, comment" is a chain people build on purpose — and it is also how a
rule ends up triggering itself. Three guards run at once, because each one alone
has a hole:

- **A depth cap of three.** A write made by a person is depth 0; the runs its
  events cause are depth 1, and anything those cause is depth 2. Past three the
  run is refused and recorded rather than executed. Two would have been too
  tight: one rule setting off another is a legitimate pattern.
- **No-op suppression.** A write that sets a field to the value it already held
  is not a change, emits nothing, and therefore triggers nothing. This is what
  kills the commonest loop of all — two rules setting the same field back and
  forth.
- **A circuit breaker.** If one rule runs more than **20 times against the same
  ticket within a minute**, that rule is **switched off** — `enabled` becomes
  false — and the reason is recorded. It catches what the other two miss: a rule
  that makes a real change every time, in a cycle longer than the depth cap,
  spread across separate user actions.

The third one is worth designing around, because it is the only guard that
changes your rule. A rule that has switched itself off stays off until somebody
turns it back on with a `PATCH`.

## Editing, disabling and deleting

Rules carry a `version`, exactly as tickets do. `PATCH` and `DELETE` both
require the version you read and answer `409` — carrying the current version —
if somebody wrote first. The reasoning and the retry strategy are the same ones
in [Concurrency and polling](/docs/agents-and-api/concurrency-and-polling).

```bash
# Switch a rule off. This is the same write as any other edit.
curl -X PATCH https://api.laver.app/boards/$BOARD/automations/$RULE \
  -H "Authorization: Bearer $LAVER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "version": 1, "enabled": false }'

# Delete it. The version goes in the body.
curl -X DELETE https://api.laver.app/boards/$BOARD/automations/$RULE \
  -H "Authorization: Bearer $LAVER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "version": 2 }'
```

There is no separate enable/disable endpoint: disabling is `PATCH` with
`enabled`, so it takes the same concurrency check as every other edit. A `PATCH`
carrying nothing but a version is a `400` — it would burn a version number
without changing anything.

Deleting takes a version for the same reason editing does: the rule somebody is
discarding should be the rule they last looked at.

**Deleting a rule deletes its run history with it.** The runs are diagnostic —
they answer "why didn't my rule fire?" — and there is nowhere to read the runs of
a rule that no longer exists, since every run is reached through its rule. What
is kept instead is the `automation_rule.deleted` audit entry, which records who
deleted it, the rule's full definition including `run_as_user_uuid`, and
`runs_deleted`: how many run rows went with it. Anything a rule actually did to a
ticket is on that ticket's own history, marked with `metadata.automation`, and
that is not deleted with the rule.

## From an agent

The [MCP server](/docs/agents-and-api/mcp-server) exposes two of these:

| Tool                | Notes                                                    |
| ------------------- | -------------------------------------------------------- |
| `list_automations`  | Every rule on a board, with its `version`                 |
| `create_automation` | Needs owner or admin; the rule runs as the key's user indefinitely |

There is deliberately no tool for editing or deleting a rule, and none for
reading run history — an agent that can create a rule and list what is there is
the useful half, and both of the others need a `version` read first.

## What this is not

- **No scheduled or due-date triggers**, and no manual trigger buttons.
- **No branching over related tickets**, and no expression language — Jira's
  smart values have no equivalent here.
- **No `OR` and no nesting** in conditions.
- **No ticket-creating actions**, as above.
- **No workspace-wide rules.** Every rule names a board.
