Triggers

Start runs when nobody is watching — on a schedule, a repository event, an incoming webhook, mail arriving, a calendar event, a call that ended, or Peractor's own events: the board, milestones, gates, metrics, memory and your organization.


Usually a run starts because a person pressed something, or because a task entered a status. A trigger is the third door: it binds an event to a workflow run, so work can start when nobody is watching. A nightly dependency sweep. A review of every incoming pull request. A triage run for each alert a service posts.

It is one thing rather than eight. A clock, a repository event, and anything a third party can POST are the same shape — event, filter, workflow, input — so a source Peractor has never heard of is a configuration file rather than a feature request.

Triggers are project configuration, edited as a form in the Triggers library on every surface. Authoring one takes Manage automations.

A triggered run creates no task

This is the decision everyone asks about, so: a trigger starts a run and files nothing on the board.

The backlog is a human artifact — rows people write, prioritize, drag between statuses, and sync back to a tracker. A nightly sweep that runs thirty times a month is not backlog. Worse, a task row syncs outward: your tracker connectors would start pushing machine-generated issues into somebody's Linear.

The cost is real and accepted: work that started itself is visible in Runs and nowhere else. Board-facing automation still works the other way round — a triggered workflow can create or move a task as a step's side effect.

Where events come from

Three sources come from outside — a clock, your repository, and anything that can POST. The rest are Peractor's own events, further down: a run, a monitor, the board, a milestone, a stored metric, a memory proposal, and your organization's audit log.

schedule — a clock

# trigger/nightly-deps.yaml
id: nightly-deps
name: Nightly dependency upgrade
on: schedule
schedule: "0 3 * * *"          # 5-field cron, UTC
workflow: dependency-bump
title: "Dependency upgrade {{ now.date }}"
prompt: |
  Upgrade every non-major dependency, run the test suite, and open a PR.
  Skip anything with a failing peer range.

Five-field cron, always UTC. The form echoes the expression in plain language — "every day at 03:00 UTC" — and lists the next three fire times, because a cron nobody can read is the most common way a schedule ships wrong.

Firing is minute-granular, and missed minutes are not caught up. A 03:00 sweep the server was too busy to reach must not run at 09:00 — the person who wrote it read it as a time of day. The gap in the delivery log is the record that it was missed.

vcs — something happened in the repository

# trigger/pr-review.yaml
id: pr-review
name: Review incoming pull requests
on: vcs
event: [pull_request.opened, pull_request.synchronize]
branches: ["main", "release/*"]    # matched against the PR's base branch
paths: ["src/**", "packages/**"]   # fires when a changed path matches
ignorePaths: ["docs/**"]           # …unless every change is in here
workflow: review
key: "pr-{{ event.pr.number }}"    # this event's identity
concurrency: cancel-previous
title: "Review #{{ event.pr.number }}"
prompt: |
  Review pull request #{{ event.pr.number }} ({{ event.pr.url }}).
  Head branch: {{ event.pr.head }}. Base: {{ event.pr.base }}.

The events are push, tag, pull_request.opened, pull_request.synchronize, and pull_request.closed. Pull requests are split because the three actions are genuinely different work.

Reviews on the pull requests people wrote are events too: review.approved, review.changes-requested, review.commented and pull_request.commented. The event carries the pull request, as above, and the review — {{ event.review.author }}, {{ event.review.body }}, {{ event.review.state }}, and review.path and review.line for a comment on a line. A comment is data, never an instruction: what happens next is the trigger's workflow, which somebody allowed to set it up chose. Reviews by Peractor itself are never heard. GitLab sends approvals and comments but nothing for "changes requested", so that one never fires there. On GitHub, the Peractor app needs the Pull request review, Pull request review comment and Issue comment events ticked.

branches matches the pushed branch or a pull request's base. paths matches the event's changed files, and any one hit fires. ignorePaths is the other half — useful for generated files, changelogs, lockfiles, and your wiki folders. Leave any of them out and they filter nothing.

Events arrive through what your connected tracker already set up — no second webhook to configure, and a trigger only ever hears about its own project's repository. For GitHub that is the Peractor app; for GitLab it is the webhooks Peractor provisioned when you connected, with merge requests speaking the same pull_request.* vocabulary. Other providers ride on webhook.

webhook — anything that can POST JSON

# trigger/sentry.yaml
id: sentry
name: Triage new Sentry issues
on: webhook
secret: ${SENTRY_HOOK}         # a name in your organization's secret store
workflow: triage
key: "{{ event.issue.id }}"
concurrency: skip
title: "Triage: {{ event.issue.title }}"
prompt: |
  A new error was reported. Reproduce it, find the cause, and open a fix PR.

  {{ event | json }}

The editor shows the endpoint — POST /webhooks/trigger/<id> — with a copy button and a ready-made curl. Peractor imposes no schema on the body: whatever JSON you post is the event, verbatim, and your templates read it by path.

The signing secret

Calls must be signed: an HMAC-SHA256 of the raw body under the trigger's secret, sent as x-peractor-signature.

The secret is a reference to a name in your organization's secret store, never a value in the file, and never something Peractor generates for you. So the field is a picker over your secret names, with a link to add one.

There is no unsigned mode. A webhook trigger with no secret will not save, and a name that resolves to nothing fails closed — the call is recorded as Rejected and nothing starts.

The endpoint answers 204 to everything, always. A status that varied would tell an unauthenticated caller which trigger ids are real. Check the delivery log, not the response code.

Peractor's own events

Much of what happens in Peractor is an event too: a gate waiting, a task labelled, a figure crossing a line, somebody joining. These sources react to it. Each one is offered only the events it names, so a trigger listening for new tasks is not told about every comment — and its delivery log stays readable.

Two rules hold for all of them. A triggered run still files nothing on the board. And a change a triggered run made never starts that same trigger again: the chain of triggers behind a run is carried on what it does, and a trigger already in it is refused as a loop, as is any chain five triggers long.

run — something happened to another run

on: run
runOn: [gate.opened, waiting, cost]
waitingFor: 6h          # how long a park counts as too long
costOver: 20            # US dollars, once per run
runWorkflow: deploy     # only this workflow's runs; absent is any

The events are completed and failed — the default — plus gate.opened, gate.approved, gate.changes-requested, question.asked, plan.applied, waiting (parked at a gate, on a question, a preview or an interactive step for longer than waitingFor) and cost (spend past costOver). A gate that has waited six hours can escalate itself; a run that fails can start the one that fixes it.

monitor — a URL went down or came back

monitor names one of your dashboard's monitors, and monitorOn says which change: down (the default), up, degraded, certificate, or change for any of the first three.

task — the board changed

on: task
taskOn: [labeled]
labels: ["needs-spec"]  # globs over the task's labels
workflow: spec-writer
title: "Spec {{ event.task.key }}"
prompt: |
  Write a spec for {{ event.task.title }}.

The events are created (the default), labeled, assigned, commented, unblocked, due (within dueWithin of its due date: a day, three, a week or a month) and overdue. For labeled the label just added must match labels; for the rest, any label the task carries.

Two things are left out on purpose. A status move is not an event here — a phase's own status already runs the task's workflow, and a second workflow racing it would be trouble. And a task a tracker sync or an import brings in is not "created": a first sync of two thousand issues must not start two thousand runs.

milestone — coming due, or completed

milestoneOn is completed (the default) or due within dueWithin, and milestone narrows it to one. A completed milestone carries how many of its tasks were done — the start of its release notes.

metric — a stored value crossed a line

on: metric
metric: org/signups.daily   # org/ for the organization's figure
metricOn: below             # above (default), below, change, stale
threshold: 100

Any value a run or the public API writes to the data store. It fires once per crossing: a value that stays above its line does not start a run on every write, and the first write of a key crosses from nothing. change fires on any new value, and stale when nothing has written the key for staleAfter.

memory — a proposal is waiting

memoryOn is waiting (the default — a proposal undecided for longer than waitingFor) or proposed. Pair it with the Memory upkeep pack, so the tidy-up runs when there is something to tidy rather than on a calendar. Only a project's memory starts anything; the organization's has no project to run in.

audit — something changed in your organization

on: audit
audit: [member.invite-accepted, member.removed]

One source over the audit log, for onboarding and offboarding: members invited, joining, changing roles or leaving; roles, projects, API keys, secrets, connectors and machines added or removed. Saving one needs View audit log, since it reads what that log does. What a run or a trigger did never starts it.

mail — a message arrives in your mailbox

on: mail
mailbox: …            # one of your own mailboxes, or a shared one, chosen in the editor
mailWhen:
  - { field: to, op: is, value: support@acme.com }
file: triage          # optional: file it as a task instead of a taskless run
workflow: customer-reply
title: "{{ event.mail.subject }}"

A message arriving in one of your connected mailboxes starts work in this project, when it matches the same conditions mail rules use: from, to, subject, the text, a folder, files, a mailing list. The trigger acts as you — only you can point it at your mailbox, someone else editing it can't re-aim it, and it stops when you disconnect the mailbox or lose the project.

A team address such as support@ is better connected as the organization's shared mailbox. A trigger on one names no owner and acts as the organization; anybody who manages this project's automations may point it at a shared mailbox the project is given, and it stops if the mailbox is disconnected or taken off the project.

One conversation is one piece of work. A reply never starts a second run: with file, the conversation becomes a task (into triage, or a status that runs it) and later messages land on the task's activity; without it, the run carries the conversation in its workspace and every later phase reads it again. Out-of-office replies, bounces, bulk mail and what the mailbox sent itself start nothing, and a trigger starts at most five runs an hour for one sender and thirty for all. Nothing in a message approves, starts or sends anything: its words reach the run only as material. Templates read {{ event.mail.from }}, {{ event.mail.subject }} and {{ event.mail.body }}.

calendar — before, at or after an event

on: calendar
calendarOn: before    # before, start (default), end or after
calendarOffset: 30m   # 5m, 10m, 15m, 30m, 1h, 2h or 1d, with before or after
calendars: [project, organization]   # personal adds your own
titleContains: Customer

"Thirty minutes before any customer meeting, prepare a brief." It hears the project's calendar and the organization's, and your own when you add personal — nobody else's. Each occurrence of a repeating event fires once. An event moved before it fires fires at its new time; one moved after it fired doesn't fire again; a cancelled one fires nothing, and a run it already started goes on. Moments are checked every minute, and one more than fifteen minutes past is let go. Templates read {{ event.calendar.title }}, {{ event.calendar.start }} and {{ event.calendar.description }}.

booking — when a time is booked, moved or cancelled

on: booking
bookingOn: booked   # booked (default), rescheduled or cancelled
calendars: [project, organization]   # personal adds your own
bookingType: <id>  # one kind of meeting; leave out for every kind

"When someone books a demo, draft a brief from their answers." It hears the bookings that land in the project's calendar and the organization's, and in your own when you add personal. Each booking fires once for each of the three. What a visitor typed reaches the run as material, never as instructions. Templates read {{ event.booking.title }}, {{ event.booking.start }}, {{ event.booking.invitee.name }}, {{ event.booking.invitee.email }} and {{ event.booking.answers }}; a move adds {{ event.booking.previousStart }}, a cancellation {{ event.booking.reason }}.

meeting — after a call

on: meeting
meetingOn: transcript   # ended, transcript (default) or recording
calendars: [project, organization]
titleContains: Weekly sync
workflow: meeting-notes

"When the weekly sync's transcript is ready, write the notes." It hears the calls on the project's events and the organization's — a personal event has none — once for each time the call was held: as it ends, once its transcript is ready, or once its recording is. attendee narrows it to calls a member joined. A transcript that never comes starts nothing, and the delivery history says why. The run gets .peractor/meeting/transcript.md and the meeting's details as files; templates read {{ event.meeting.title }}, {{ event.meeting.attendees }} and {{ event.meeting.transcript }}. The Office work pack's Meeting notes workflow is made for it.

message — a new message in a chat channel

on: message
channels: [<id>]       # public channels, or private ones you're in
contains: bug           # optional: any of these words
from: { roles: [support] }   # optional: people and roles
messages: roots         # roots (default), replies or both
has: file               # optional: file, link, voice or poll
workflow: triage-bug

"When someone reports a bug in #support, triage it." It hears new messages people write in the Chat channels it names — never a direct conversation, never an edit, and never a message written by a workflow, a persona or Peractor. It acts as the person who saved it, and stops when they leave a private channel it reads or lose the project. Each match starts a run; templates read {{ event.message.text }}, {{ event.message.author }}, {{ event.message.channel }} and {{ event.message.link }}, and the message's files are in .peractor/message/. A run it starts that notifies the same channel through the Peractor chat notifier answers in the message's thread. Test matches the trigger against the channel's last 50 messages.

Events that are a state, not a write

Coming due, going overdue, waiting too long and going stale are noticed on a clock, every five minutes — so expect a few minutes' delay. Each fires once per occurrence: a task overdue for a week is one run, and a new due date is a new occurrence. A trigger that is switched off is not told about them at all, so switching it back on still hears about the task that is still overdue.

What every trigger sets

  • Workflow — which of the project's workflows to run. Leave it empty for the project's default.
  • Run title and prompt — templates. The prompt becomes the run's description, which is where a task's description would have gone.
  • Identity (key) — "two events with the same identity are the same work". Both the duplicate check and the concurrency policy key on it. The default is the trigger's own id, which is right for a nightly sweep and wrong for a per-pull-request review.
  • If one is already running — queue it (the default), skip this one, or cancel the running one.
  • Enabled — the switch, and the first thing to check.

A one-sentence summary sits at the top of the pane, assembled from those fields: "Every pull request opened or updated against main or release/*, when it touches src/**, runs the Review workflow." It is the fastest way to catch a trigger that no longer does what its name claims.

Narrowing, pacing and acting as you

Five settings work the same on every source.

filter: 'mail.from == "*@acme.com" && mail.subject != "*unsubscribe*"'
perHour: 4          # at most four runs an hour
perDay: 20          # …and twenty a day
debounce: 10m       # one run once things go quiet for ten minutes
personal: true      # act as me
  • Filter — one condition over the event's fields, written the way a phase's when: is: clauses joined by && or by || (never both), each a field, !field, field == "pattern", field != "pattern", or a number compared with <, <=, > or >=. Fields are the templates' names — mail.from, pr.base, task.labels, metric.number, a webhook body's own fields — and text compares without regard to case, with * matching anything. A list matches == when any member does. The source's own fields (branches, paths, labels, mail conditions) are checked first, and the filter has the last word; Test prints the values it read.
  • At most — perHour and perDay cap the runs one trigger starts, counted from its delivery log so a restart forgets nothing. A capped event is skipped with the count it hit. Mail triggers start at thirty an hour unless you say otherwise. Your organization's monthly quota still applies on top.
  • One run for many events — collect: 1h gathers an hour of events into one run; debounce: 10m waits until ten minutes pass with nothing new, or a day at most. Pick one, from 1m to 24h. Each event that joins is logged as Collected with the time the batch closes. The run's templates read {{ events | json }} and {{ batch.count }}, and {{ event.* }} is the latest one. Run now never batches.
  • Acts as me — personal: true makes the trigger act as the person who turned it on: its runs start as them, open only what they may, and their gates and questions reach them. Nobody can make a trigger act as somebody else, and when they leave the project or lose Start runs it stops. Mail and calendar triggers always act as their owner.
  • File as a task — on webhook, as on mail, file: triage (or a status) files each request as a task instead of starting a taskless run. Every other source stays taskless. Saving one takes Create tasks.

Templates are text, not code

{{ event.pr.number }} is a dotted path into the event. {{ now.date }} and {{ trigger.name }} work from any source. And {{ event | json }} inlines the whole payload as a JSON block — the escape hatch for every field a normalized event does not name, and the reason a webhook needs no schema.

That is the entire language. No expressions, no conditions, no loops, nothing evaluated — the filter above is a fixed grammar of comparisons, not code. The constraint is the feature: a trigger file is configuration that a stranger's webhook payload flows through, so anything evaluable here would be remote code execution wearing a template's clothes.

An unknown path renders empty, on purpose. A misspelled filter is the opposite case — always a typo — so {{ event | jsn }} is refused when you save.

The guards

Be honest about what this feature is: it spends money without being asked. Six guards make that safe, and none of them is optional.

  • Enabled. Off means off — but recorded, so "why didn't it fire" has an answer.
  • The loop guard. Repository events Peractor caused are ignored: a push to one of its own branches, a pull request from one, or a commit made under your organization's own git identity — the committer of everything Peractor commits, whoever authored it. Without it, one push trigger with a committing workflow is an infinite loop that bills by the container.
  • Duplicates. Every delivery carries an id — the provider's own where there is one, a digest of the payload otherwise. A repeat starts nothing. GitHub redelivers, and a pull request updated twelve times in three minutes is twelve events.
  • Concurrency. Per identity, per your policy. Queuing uses the same queue every other start path uses.
  • At most. The trigger's own perHour and perDay, when it sets them.
  • Quota. The identical check a person's click makes. Past the monthly quota a trigger is rejected; at the concurrency cap it queues. A trigger can never start a run you could not. See Plans & usage.

Proof: test, run now, and the delivery log

A trigger that silently does nothing is unusable, and it is this feature's normal failure mode. So every evaluation is recorded — fired or not:

OutcomeMeaning
FiredA run started.
QueuedA run was created and waits — behind an earlier run of the same identity, or behind your concurrency cap.
SupersededCancel-the-running-one stopped the live run and started a fresh one.
CollectedThe event joined a batch, which starts one run when it closes.
SkippedA guard said no: disabled, a filter missed, the loop guard, skip-this-one, or the at-most cap.
DuplicateThis delivery had already been seen.
RejectedRefused before the guards — an unverifiable signature, an unresolvable secret, or a spent quota.
FailedThe evaluation itself broke. Recorded, so a broken trigger is visible rather than silent.

Every row carries its reason as a sentence you can act on — "the pushed branch docs/typo matched none of main" — plus the identity it keyed on and a link to the run when there is one. The log keeps 30 days.

Two buttons sit beside it. Test takes a sample or pasted payload and shows what would happen — the rendered title and prompt with the substitutions highlighted, and a verdict per guard rather than one pass or fail. Nothing starts, nothing is recorded, no quota is spent. Run now is the other half: fire it for real and land on the run. Test takes Manage automations, like editing; Run now takes Start runs, like starting any run.

Nobody is waiting on a triggered run, so no notification goes out when it starts. Its header says what started it and links back to the trigger, and the Runs list filters to Triggered. Gates, questions, and previews notify normally once the run needs a person.

What a trigger may do

A saved trigger's page has a Permissions section for people with Manage roles — on the web, the desktop app, the Mac, iPhone, iPad and Android — and nobody else sees it. It is the role editor's list: tick what the trigger may do, from the permissions you hold yourself, and Save. Peractor keeps the list itself, never in the trigger's file.

  • Without a list the section reads "Acts with no identity: it sees only what Everyone can." Give it permissions of its own starts one.
  • With a list the trigger holds only what is ticked, not what Everyone holds, and what Everyone is denied, it is denied too. Take its permissions away goes back to no list.

The list is what See as… and Why? answer for the trigger in its project, and rows on the project's Access page can name it — see Private projects & access. It also decides its runs: a run the trigger starts includes the restricted folders and files its list opens, without asking, and when that still leaves files out, Peractor pushes its changes only to files the list may edit. A trigger with no list includes nothing.

That is why changing a trigger that has a list takes every permission in it: "Changing “Nightly” needs every permission it holds." Nobody can put what it may reach to work they couldn't do themselves. Removing a trigger, or giving it a new id, takes its list and its rows with it.

Next: Workflows · The run lifecycle · GitHub