Triggers
Start runs when nobody is watching — on a schedule, on a repository event, or on any incoming webhook.
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 is admin-only.
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.
Three sources
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.
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.
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.
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 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. Five 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 authored by your organization's own git identity. 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.
- 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:
| Outcome | Meaning |
|---|---|
| Fired | A run started. |
| Queued | A run was created and waits — behind an earlier run of the same identity, or behind your concurrency cap. |
| Superseded | Cancel-the-running-one stopped the live run and started a fresh one. |
| Skipped | A guard said no: disabled, a filter missed, the loop guard, or skip-this-one. |
| Duplicate | This delivery had already been seen. |
| Rejected | Refused before the guards — an unverifiable signature, an unresolvable secret, or a spent quota. |
| Failed | The 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.
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.
Next: Workflows · The run lifecycle · GitHub