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

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#

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

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

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

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

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

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

ticket.due#

json
{ "trigger_type": "ticket.due", "trigger_config": { "when": "after", "days": 3 } }
when Fires days
arrives As the due day begins not allowed
before days days before the due day begins 1 to 365
after days days after the due day begins 1 to 365

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

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

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

A due-date rule is not retroactive#

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

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

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

Actions#

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

call_webhook is the one action that leaves Laver, and it carries a caveat the others do not. The destination is resolved and checked against private, internal, loopback and link-local addresses when the rule runs, not when it is saved — a hostname that resolved somewhere harmless at save time can be repointed later, so a save-time check is one that expires. It is the same check outbound webhooks 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:

json
{ "field": "priority", "operator": "is", "value": "high" }
Field Operators value
status is, is_not A status uuid
priority is, is_not none, low, medium, high, urgent
label has, has_not A label uuid
assignee has, has_not, is_set, is_not_set A user uuid
due_date is_set, is_not_set
title contains, not_contains Text

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

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

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

Limits#

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

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

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

A rule is a standing grant, not an action#

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

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

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

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

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

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

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

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.

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 OR and no nesting in conditions.
  • No ticket-creating actions, as above.
  • No workspace-wide rules. Every rule names a board.