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.
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.
Automations are the same events as outbound 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 below for why writing is not.
Create one#
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" }
]
}'
{
"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.
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 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#
{ "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#
{ "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 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 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 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:
{ "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 (
PATCHwith"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.
Run history#
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 —
enabledbecomes 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.
# 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.
From an agent#
The 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
ORand no nesting in conditions. - No ticket-creating actions, as above.
- No workspace-wide rules. Every rule names a board.