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 |
task_uuid |
no | The ticket a column.empty rule is about. Required by that trigger, refused by every other one |
run_limit |
no | How many times a column.empty rule may fire, 1 to 100. Left out, it fires every time. Refused by every other trigger |
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 |
ticket.in_column |
A ticket has sat in one column for N days |
column.empty |
A named column holds nothing, so one ticket moves |
manual |
Somebody presses the rule's button on a ticket |
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. schedule, ticket.due, ticket.in_column
and column.empty are not events at all — see
Triggers that run on a clock — and manual is
neither an event nor a sweep, see Buttons.
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. ticket.movedis considered against both boards. A move concerns the board a ticket left and the board it arrived on, and a rule on either one fires. A rule has no way to say which side it meant, and both readings — "when a ticket leaves my board" and "when a ticket arrives on my board" — are ones people write, so the trigger answers to both. Two rules on the two boards is two runs; one rule never fires twice for one move.- That is what makes a rollover reachable. A rollover creates its destination
board inside the same transaction that moves the tickets, so there is no
moment at which anybody could attach a rule to it. The board you can pin a
rule to is the sprint board being rolled off, and that is the one that
fires. Before this, a board-scoped
ticket.movedrule could not see a rollover from either side, and a workspace-wide rule was the only shape that could.
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, wiki_page.updated and the four publishing events
(wiki_page.published, wiki_page.unpublished, board.published,
board.unpublished). Those are real events and none of them is a trigger —
they were left out deliberately rather than missed. The publishing four are the
clearest case: a rule's conditions and actions are about one ticket, so a rule
triggered by a wiki page going public would have no ticket to act on, and a
trigger nothing can act on is the same silent no-op enumerating the list exists
to prevent. Send those to a webhook or a chat destination instead. Where the names do overlap they mean the same occurrence,
so the event table is the description of both.
Buttons: the manual trigger#
A manual rule waits for nothing. It appears as a button in the ticket dialog,
under Automations, and runs when somebody presses it — Trello's card
buttons, and the same idea.
{
"name": "Send to review",
"trigger_type": "manual",
"actions": [{ "type": "move_to_status", "status_uuid": "…" }]
}
It takes no trigger_config: there is nothing to configure, because which
ticket it runs on is decided by which ticket the person had open.
Press it over the API with the ticket you want it to act on:
POST /boards/:board_uuid/automations/:rule_uuid/run
{ "task_uuid": "…" }
The reply is a 202 with the queued run. It is 202 and not 200 because the run is a pending row the worker picks up on its next tick — the run history is where the outcome appears, not this response.
Four things worth knowing before you build on it:
- Pressing twice runs twice. Each press is its own run. That is what a button should do, and it is the one place this feature deliberately differs from the scheduled triggers, which collapse a repeated occurrence.
- The presser becomes the run's actor, not its authority.
{{actor.name}}prints their name and anassign_useraction set to whoever triggered it assigns them — but the rule's actions still execute under the rule'srun_as_user_uuid, exactly as they would if an event had fired it. Pressing a button does not lend you the rule's permissions, and it does not lend the rule yours. - Creating one is admin-only; pressing one needs write access on the board.
That is deliberately wider than the rest of this API, and it widens nothing:
anybody who can drag a card can already fire
ticket.movedat will, so a button is the honest version of a trigger they control anyway. A read-only role gets a403. - A board may hold twenty buttons. Every one is a row in the action menu of
every ticket on the board, so the cap is about a menu staying readable. The
twenty-first is refused with a
400naming the limit.
A rule with any other trigger_type refuses that route with a 409. A
scheduled rule must not become something anybody can fire on a Thursday.
Triggers that run on a clock#
schedule, ticket.due, ticket.in_column and column.empty are the triggers
no action of yours produces. Nothing happens at the moment Monday 09:00 arrives,
at the moment a ticket becomes overdue, at the moment a ticket has been sitting
somewhere for three days, or at the moment the last card leaves a column — so
they are found by a sweep instead of by an event. All four require a
trigger_config; the others ignore it.
These are API-only in the rule builder. It writes rules as a sentence and
has nowhere to put an interval, a threshold or a column, so it offers the event
triggers only. A swept rule is created through the API — it then appears in the
list like any other rule, and can be disabled and deleted there. column.empty
is the exception with a place of its own in the app: the ticket dialog has a
Move when a column frees up control, because that rule is about the ticket
in front of you rather than about the board.
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.
Except when it only creates tickets. A rule whose actions are nothing but
create_linked_ticket is about the board rather than about any ticket, so it
fires once per slot and carries no ticket at all. That is what makes "every
Monday at 09:00, create a ticket called Weekly release checklist" do what it
says: with the fan-out it would create nothing on an empty board and one
checklist per existing ticket on a full one. Because such a rule has no ticket,
it may not carry conditions, and it may not be mixed with actions that act on
tickets — both are a 400 naming the problem. Use two rules.
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.
ticket.in_column#
{
"trigger_type": "ticket.in_column",
"trigger_config": { "status_uuid": "…", "days": 3 }
}
Fires when a ticket has been sitting in one column for days days. This is the
one for "anything still in Review after three days goes back to To do", and it
is what a CRM board wants for a lead nobody has picked up.
| Field | Required | Notes |
|---|---|---|
status_uuid |
yes | Which column. There is no "any column" |
days |
yes | Whole days, 1 to 365 |
Both are required and a missing one is a 400. Without a column the rule would
mean "anywhere on the board", which fires for every ticket that stops being
touched; without a threshold there is nothing to compare against.
Unlike a due date, time in a column is an instant, not a calendar day. A ticket that arrived at 14:32 crosses a three-day threshold at 14:32 three days later, not at midnight.
Moving a ticket resets its clock. Arriving in a column starts the count from zero, so a ticket that spent a month in To do and then moves to Review has been in Review for no time at all. Only a column change resets it — a ticket moved between boards without changing column keeps its clock.
Each stay is counted separately. A ticket that leaves a column and comes back later can fire the same rule again, because the second stay is a new one.
The loop this can cause#
A rule that moves a ticket resets that ticket's clock in its new column. Two dwell rules pointing at each other — "stale in A goes to B", "stale in B goes to A" — is a cycle, and the depth guard does not stop it: a swept run deliberately starts a fresh chain, so it never reaches the cap.
What bounds it is the threshold. Each hop has to wait out the whole days again
before it can fire, so the worst case is one move per ticket per threshold
rather than a ticket bouncing continuously. That is the reason days is whole
days with a floor of one, and the reason sub-day granularity is not offered.
Not retroactive either#
Same rule as ticket.due, and it matters more here because most boards have
something that has been sitting somewhere for months. A new rule does not
reach back: writing "move anything stale in Review" on a board where forty
tickets are already stale does nothing, rather than moving the column in one
tick. Those tickets have to enter the column again to qualify.
Archived tickets are never considered.
column.empty#
{
"trigger_type": "column.empty",
"trigger_config": { "status_uuid": "…" },
"task_uuid": "…",
"run_limit": 1
}
Fires when the named column holds no tickets, and moves the ticket named by
task_uuid — the queue behaviour a WIP limit implies: "start this one the
moment Doing is free".
| Field | Required | Notes |
|---|---|---|
trigger_config.status_uuid |
yes | The column being waited on |
task_uuid |
yes | The ticket that moves. Set once, at creation |
run_limit |
no | How many times it may fire, 1 to 100. Left out, it fires every time. Set once, at creation |
This is the only trigger that names a ticket. Every other rule acts on the
ticket that set it off, so task_uuid on one of those is a 400 rather than
being ignored — a rule that silently dropped its scope would look like it was
about one ticket and behave like it was about the board. The ticket has to be a
live one on the rule's own board; a rule whose ticket is later deleted goes with
it.
task_uuid cannot be changed by PATCH. A rule about the wrong ticket is
deleted and written again — the alternative is a rule whose history describes
work it was never about.
How many times it fires#
A rule with no run_limit is a standing one. It moves the ticket, and if
the ticket ever leaves that column and the column empties again, it moves it
back. That is deliberate for a ticket somebody is holding against a column, and
usually not what a one-off queue entry meant, so say which:
{ "run_limit": 1 }
run_limit counts every run the rule has started — the successes and the
failures both, because a rule that failed twice out of two has been given its
two goes and retrying it forever is how a broken rule becomes a loop. Runs that
never got as far as doing anything — no_match, skipped, blocked,
depth_exceeded — do not count against it.
A spent rule switches itself off. It comes back from GET with enabled
false and a disabled_reason saying it finished, so a rule that stopped because
it was asked to and a rule that stopped because it was broken can be told apart
by reading it rather than by counting its runs. Switching a spent rule back on
does not give it more runs — it is already past its limit and will retire
itself again on the next sweep. Write a new rule instead.
Like task_uuid, run_limit is set at creation and cannot be changed by
PATCH: it is measured against the runs the rule has already had, so lowering
it would retire a rule retroactively and raising it would resurrect one.
It fires as soon as the column is empty, including immediately. There is no threshold to wait out and, unlike the other swept triggers, no retroactivity rule: a column that is already empty when you write the rule is empty, and the ticket moves on the next sweep. That is the point of it. The reason the other three hold back — a new rule catching up on a board's whole history in one tick — cannot happen here, because the rule moves exactly one ticket and then stops matching.
It is a sweep rather than an event because a column empties in more than one way. A ticket can be moved out, archived, or deleted outright, and the last of those emits nothing at all — so a rule listening for events would work until the day somebody emptied the column the third way.
Two rules waiting on the same column will both fire on the same sweep and both tickets will land there. Ordering one before the other is a WIP limit, which Laver does not have yet.
Archived tickets are ignored on both sides: an archived ticket does not keep a column occupied, and a rule whose own ticket is archived does not fire.
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 |
set_blocked_due_date |
Sets the due date of the tickets it blocks |
add_comment |
Comments on the ticket |
archive_ticket |
Sends it to the trash |
call_webhook |
Posts the ticket to a URL |
create_linked_ticket |
Creates a new ticket |
set_blocked_due_date takes the same due_date as set_due_date — a date like
2026-08-20, or an offset like +3 days — and writes it to every ticket the
triggering one blocks, which is what makes "when this is finally done, give
the work that was waiting on it three more days" a rule rather than an afternoon
of editing. It writes to all of them, whether or not another blocker still holds
them: "it was waiting on this one" is true of every ticket in the list. A
dependent on a board the rule's user cannot open is skipped rather than failing
the run, because dependencies are workspace-wide and can cross boards, and a
ticket that blocks nothing is a successful no-op.
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.
create_linked_ticket#
The one action that makes a ticket rather than changing one.
{
"type": "create_linked_ticket",
"title": "Release checklist {{now}}",
"description": "Cut the **release** branch",
"status_uuid": "…",
"relationship": "related_to"
}
Only title is required, and it is templated — {{now}} in a
title is what makes a weekly checklist a different ticket each week. description
is templated too and is parsed as markdown, exactly as it is on
POST /boards/:board_uuid/tasks. Omit status_uuid and the ticket lands in the
board's first column, which is where adding a card by hand puts one.
relationship links the new ticket to the one that triggered the rule, using the
same vocabulary as ticket relationships:
blocks, blocked_by, related_to, duplicate_of — stated from the new
ticket's point of view, so blocked_by means the new ticket waits on the one
that triggered it. A scheduled rule has no triggering ticket, so asking for a
relationship on one is a 400 rather than a link that silently never appears.
The ticket is created on the rule's board, by the person the rule runs as, and it
emits ticket.created like any other new ticket — no exemption. That means a
rule triggered by ticket.created whose action creates a ticket feeds itself,
and is stopped by the depth cap after three chained runs, recorded as
depth_exceeded in the run history with a sentence saying why. That is the
guard doing its job rather than a bug; if you want a chain, keep it under three.
There is still no notify_user. add_comment already does it: an automated
comment resolves @mentions and delivers the same notifications a person's
comment does, and leaves a record on the ticket saying why somebody was pinged. A
separate notify action would be a second, invisible channel for something already
achievable.
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, has_passed, has_not_passed |
— |
title |
contains, not_contains |
Text |
blocking |
is_set, is_not_set |
— |
is_set, is_not_set, has_passed and has_not_passed are a whole question by
themselves and take no value — sending one is a 400 rather than a rule
that quietly ignores it. Every other operator requires a value.
due_date has_passed is true when the due day is behind the day the rule
runs, in UTC. A ticket due today has not passed its due date; it passes as the
next day begins, which is the same moment the ticket.due trigger's "1 day
after" fires. has_not_passed is the strict negation, so a ticket with no
due date matches it — pair it with due_date is_set for "dated and not yet
late".
blocking is_set is true when at least one other ticket is waiting on this one
— the same dependencies set_blocked_due_date acts on, in the same direction.
It asks only whether something is blocked, not which: there is no value to
send, because a condition list is a flat AND about one ticket and naming the far
end would make it a rule about a pair. Archived dependents do not count, and the
reverse question — whether this ticket is blocked by another — is not part of
the vocabulary.
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.
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_eventan automation causes carriesmetadata.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"andsource_idset to the rule uuid, and shows an Automation badge next to the author. Its author is still therun_asuser, which is what makes an@mentionin it notify properly. - Webhooks. The same
automationfield appears in the outboundticket.*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/.deletedentry, recording who did it and — the point — therun_as_user_uuidit 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#
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.
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 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 manual trigger buttons. Scheduled and due-date triggers DO exist — see
above — and an earlier revision of this list said they did not, for long enough
that it is worth naming.
scheduleandticket.dueare real. - No branching over related tickets, and no expression language — Jira's smart values have no equivalent here.
- No
ORand no nesting in conditions. - No
notify_user. Deliberate, andadd_commentis the answer — seecreate_linked_ticket. Creating a ticket, which this list also used to rule out, now exists. - No workspace-wide rules. Every rule names a board.