Laver docs
Open Laver
Agents and API

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#

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
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.moved is 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.moved rule 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.

json
{
    "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:

text
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 an assign_user action set to whoever triggered it assigns them — but the rule's actions still execute under the rule's run_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.moved at will, so a button is the honest version of a trigger they control anyway. A read-only role gets a 403.
  • 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 400 naming 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#

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.

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#

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.

ticket.in_column#

json
{
    "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#

json
{
    "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:

json
{ "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.

json
{
    "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:

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, 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 (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 offenabled 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.

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 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. schedule and ticket.due are real.
  • 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 notify_user. Deliberate, and add_comment is the answer — see create_linked_ticket. Creating a ticket, which this list also used to rule out, now exists.
  • No workspace-wide rules. Every rule names a board.