// docs/guides/triggers.md docs online

Event triggers

A trigger starts vincent work when something happens somewhere else. Someone labels an issue, a CI build goes red, a Jira ticket reaches “Ready for Dev”, and a task is created, followed up, retried or cancelled with nobody pressing a key.

Each trigger is one YAML file under {config_dir}/triggers/. It names a source of events, a filter, an action, and a dedupe key that makes each event fire at most once. Everything a trigger does goes through a route the API already has. A created task is a POST /v1/tasks, and a reaction is the same follow_up, retry or cancel a person would send. The route’s validation, limits and state checks all apply, so from its first step on, a triggered task behaves exactly like one you created by hand.

Triggers are global. There is no project-level .vincent/triggers/, so nobody who can merge to a repository can start agents on your machine.

Turning triggers on

A trigger is off twice by default. A file does nothing until both of these are on:

  1. its own enabled: true, and
  2. triggers.enabled in config.yaml:
triggers:
  enabled: true

The second key exists because a trigger has no keypress to be your consent. Read the security model before you turn it on. See the configuration reference for the key itself.

A trigger that is enabled, valid and globally switched on is armed. Only an armed trigger polls, ticks or accepts a push. GET /v1/triggers lists every file with armed and, when it is not armed, a disarmed_reason: the file does not validate, the trigger is disabled, or triggers.enabled is off.

Arming seeds. The first poll after a trigger is armed records what the source already shows and fires nothing. That applies whether the trigger was armed by its own enabled: or by the global key. A backlog that existed before you turned a trigger on never starts work. A schedule has no poll and anchors its clock at that moment instead, to the same effect: the first occurrence it ever fires is one that falls after the trigger was armed.

The rest of the lifecycle follows from that rule:

  • Disarming drops the cursor. Setting enabled: false, or turning triggers.enabled off, forgets where the trigger had got to. Turning it back on seeds again, so events from the off period never fire.
  • A file that does not validate keeps its cursor. An editor’s half-written save does not re-seed a trigger that is about to come back unchanged.
  • Deleting the file drops the cursor too. The delivery ledger is kept, so a file re-created under the same id cannot refire an event it already delivered.
  • A restart is none of these. The cursor persists, and the next poll is a catch-up.

The daemon watches the directory and reloads a change on its own. It also reloads config.yaml, so turning triggers.enabled on or off takes effect straight away. Files the daemon writes (from the TUI or the API) are owner-only (0600), because a poll command’s argv can carry a token.

The shape of a trigger

# {config_dir}/triggers/label-to-task.yaml
id: label-to-task
enabled: true
source:
  type: github_issues
  project: 1
match:
  action: labeled
  labels: agent-please
action:
  type: create_task
  title: ''
  github_issue: ''
dedupe_key: 'gh:issue::label:agent-please'
limits:
  max_per_hour: 5
Key What it is
id Required. Lowercase letters, digits, -, _ and ., starting with a letter or digit, and equal to the file name without .yaml.
enabled The per-trigger switch. Absent means false.
source Where events come from: type (command, github_issues, github_prs, http or schedule), project, and the keys that type takes.
match A cheap prefilter: dotted paths into the event, each with the value it must have.
if A guard over .Event that must render true or false.
allowed_actors GitHub sources only: the issue or pull-request authors whose events may pass.
action What an event that passes does: create_task, follow_up, retry or cancel.
on_fire propose (the default) or create.
dedupe_key A template over .Event. Absent means the event’s id.
overrun What to do when this event’s concurrency group still has unfinished work: parallel (the default), skip, cancel_previous, queue_coalesce or queue_serial.
concurrency_key A template over .Event naming that group. Absent means the trigger id, or a reaction’s resolved task. Needs an overrun.
limits max_per_hour, and max_task_cost_usd for a create_task.
permission create_task only: restricted (the default) or workflow.

source.project is the numeric id of a project, the id that GET /v1/projects and vincent project ls --json report. It is the project a task is created in, or whose branches a reaction looks in.

Decoding is strict. An unknown key is an error, and so is a key the chosen source or action does not take, such as poll_interval on a GitHub source or title on a cancel. A key that was set but ignored would look like it does something, so vincent refuses it instead. A file that does not validate is still listed, with its errors, and never arms.

How an event is judged

Every event goes through the same steps in order. The first one that stops it decides its ledger outcome:

  1. match: Every key must hold, or the event is filtered.
  2. allowed_actors, on a GitHub source. An author the list does not name is filtered.
  3. if: A render of false is filtered. A render that is neither true nor false is an error.
  4. Dedupe. A key that already fired or was seeded is deduped.
  5. Rate limit. Once limits.max_per_hour deliveries have fired in the trailing hour, the event is rate_limited and dropped, not queued.
  6. Render. The action’s templates are rendered. A render failure is an error.
  7. Target. A reaction finds its task by branch. No task on that branch is refused.
  8. Overrun. If the trigger sets overrun:, the group its concurrency_key: names is checked for unfinished work. skip records the event superseded; a queue mode records it queued and holds it; cancel_previous cancels what it found and carries on. parallel, the default, never reaches this step.
  9. Replay. The request goes to the route. A 2xx answer is fired, a 4xx (such as the state machine’s 409) is refused, and anything else is an error.

Overrun: what happens while the last run is still going

By default a trigger fires every event that passes the filter, whatever it started before. That is right for a one-shot event and wrong for a source that emits repeatedly about the same object: a ticket saved four times in a minute starts four tasks on four worktrees, three of them stale before an agent reaches them.

overrun: says what to do instead, and concurrency_key: — a template over .Event — names the group:

overrun: skip
concurrency_key: ''
dedupe_key: ''
overrun Behaviour
parallel The default, and what every trigger did before this existed: no check at all.
skip Record the event superseded and drop it. The work in flight stands.
cancel_previous Cancel every unfinished task in the group, then fire. The new task gets its own branch and worktree; the cancelled ones keep theirs, and the delivery records which task it superseded.
queue_coalesce Hold the event. When the group empties, fire the newest held event and record the rest superseded.
queue_serial Hold the event. When the group empties, fire the oldest, then the next, and so on.

concurrency_key: is deliberately not dedupe_key:. Above, the dedupe key is per modification and the group is the ticket; setting them to the same thing would mean the trigger fires once ever, which is the behaviour this feature exists to avoid. Absent, the group is the trigger id — one task at a time for the whole trigger — and for a follow_up, retry or cancel it is the task the branch: resolved to. Setting it without an overrun: is a load error.

Unfinished means every state but done, aborted and archived — so paused, blocked, awaiting_gate and awaiting_children all hold a group. The consequential case is on_fire: propose, the default, which creates every task paused: an unreviewed proposal holds its group. A skip trigger whose first proposal nobody admits will never fire for that group again. That is intended — a second proposal for the same ticket is noise — but it is also why a trigger that fired once and then went quiet is usually waiting on a proposal, not broken.

An event a group already holds is recorded superseded rather than held twice, so a source that re-shows its whole window on every poll does not fill the backlog with copies of one event.

Held events live in the database, so they survive a daemon restart, and there are at most 100 per trigger; at the cap the oldest is dropped and recorded superseded. Disarming a trigger discards its backlog and records each held event, the same way disarming drops the poll cursor. A held event is judged again in full when it drains, so a rate limit met in the meantime is honoured and a reaction re-resolves its branch.

vincent trigger test reports the overrun decision before anything is written, naming the tasks it found in flight.

cancel_previous destroys work. An inbound event kills an agent mid-run. Nothing on disk is lost — the cancelled task keeps its branch and worktree — but nothing un-cancels it either. On a GitHub source, allowed_actors is what stands between a stranger’s event and a cancelled run.

Filtering: match: and if:

match: compares equality only. Each key is a dotted path such as workflow_run.conclusion, and both sides are compared as text, so 42 in the file equals 42 in the event. A list in the file means “any of these”. A list in the event means “contains”, so labels: agent-please passes an event whose labels is [bug, agent-please]. A path the event does not have is a miss, not an error.

Anything richer goes in if:, which is a Go text/template like a workflow’s if:. It must render exactly true or false once surrounding whitespace is trimmed.

Every trigger template (if, dedupe_key, concurrency_key and the action’s fields) sees a single root, .Event, and has the standard template builtins and no extra functions. Templates render with missingkey=error: a key the event does not carry makes the delivery an error rather than rendering empty text. Filter sparse events with match: first. JSON numbers arrive as floats, so write `` where a large number must render as digits.

Dedupe and limits

dedupe_key defaults to the event’s id. Set it when “once” means something other than “once per event id”. The file above fires once per issue for its label, so taking the label off and putting it back does not start a second task. A key that renders empty is an error.

Only fired and seeded rows count as delivered. An event that was filtered, rate_limited, refused or an error can still fire later. That covers a relabel after you change the filter, the same event an hour later under the limit, or a retry after the refusal’s cause is fixed. A superseded or queued row is not delivered either: a queued event fires when its group empties, and a superseded one fires if the source shows it again once the group is free. Ledger rows, and any events a queue mode still holds, are pruned after a fixed 30 days.

A create_task replay also carries an Idempotency-Key derived from the trigger id and a hash of the dedupe key. Two triggers that render the same key do not collide with each other, or with a script using that key itself.

limits.max_per_hour counts fired deliveries in the trailing hour, and 0 or absent means no cap. limits.max_task_cost_usd becomes the created task’s own cost cap, and 0 sends none. It is refused on a reaction, because a task’s cap is set when the task is created.

Sources

command: poll anything

source:
  type: command
  project: 1
  poll_interval: 5m
  command: [/usr/local/bin/vincent-jira-poll, --project=VIN]

poll_interval is required and must be at least 1s. command is required. It is an argv that is executed directly, never through a shell, so a pipeline belongs in a script that the argv names. The command inherits the daemon’s environment policy like every other child process, which is how it gets a vendor token without the trigger file holding one. It gets a fixed one-minute timeout, after which its whole process tree is killed.

The command prints NDJSON on stdout, one event per line:

{"id":"jira:VIN-42:2026-09-14T10:02:11Z","key":"VIN-42","fields":{"summary":"Fix the login redirect"}}
{"id":"jira:VIN-43:2026-09-14T10:04:37Z","key":"VIN-43","fields":{"summary":"Add rate limiting"}}
{"cursor":"2026-09-14 10:05"}
  • Each line is a JSON object with a string id, and the whole object is .Event. There is no normalization, so a Jira event carries Jira’s field names. Blank lines are skipped. A line that is not an object, or has no string id, is logged and skipped.
  • An optional last line holding only {"cursor": "..."} is a watermark. The daemon stores it and hands it back on the next run in $VINCENT_TRIGGER_CURSOR, which is empty on the first run. The string means whatever your command wants: a timestamp, an action id, a build number. A run that prints no cursor line leaves the stored one where it was.
  • A non-zero exit is a failed poll, never “no events”. The cursor does not advance and the poll status reads failing with the exit status and the tail of stderr. Stderr always goes to the daemon log.
  • Printing more than 8 MiB fails the poll rather than being cut short.

The seed run (the first after arming) writes one seeded ledger row per event it printed, keyed by the rendered dedupe key, and runs no match: or if:. It stores the cursor line, or an empty cursor when there is none, so a command that keeps no watermark of its own does not fire its whole backlog on the second poll.

After the seed, at most 20 events of one poll are judged. Events past that are dropped, not deferred, and a warning is logged once per trigger per daemon run. This is also the catch-up limit after a restart. A backlog of a hundred events means a hundred agent processes, so the cap is fixed.

github_issues and github_prs: watch a repository

source:
  type: github_issues
  project: 1

A GitHub source takes no poll_interval and no command. It is judged on the daemon’s existing github.poll_interval tick. On each tick, every project with an armed GitHub trigger gets at most one issues listing and one pull-request listing, whichever its triggers need. Every trigger on that project shares them, so API use does not grow with the number of triggers. A listing covers every state and up to 100 items. It starts two minutes before the oldest point the project’s triggers have seen, because GitHub’s search index lags behind recent changes.

GitHub reports state, not events, so the trigger keeps a snapshot of what it last saw and synthesizes events from the difference:

Source Events
github_issues opened, closed, reopened, labeled, unlabeled, assigned
github_prs opened, ready_for_review, review_requested, closed, merged

Every event carries id, action, author, state and number, plus the item:

  • .Event.Issue has Number, Title, Body, URL, Author, State, Labels, Assignees and UpdatedAt.
  • .Event.Pull has Number, Title, Body, URL, Author, State, HeadRef, BaseRef, Draft, Merged, Labels, RequestedReviewers and UpdatedAt.

Some events add a field of their own. labeled and unlabeled add labels (the labels just added or removed). assigned adds assignees (the new assignees), and review_requested adds reviewer (the logins newly requested). A merged pull request reads state: closed, and its Merged is true.

The default event id is github:issue:{number}:{action}:{updated-at} (or github:pull:…), where {updated-at} is Unix seconds, not the RFC 3339 form .Event.Issue.UpdatedAt carries. The same issue labelled again later is therefore a new id, so set a dedupe_key when you mean once per issue.

The first tick after arming stores the snapshot and fires nothing. After that, an item the snapshot has never seen fires opened only if it was created after the seed. An older one is taken as a baseline. A restart keeps the snapshot, so the next tick is a catch-up diff, capped at 20 events like a command’s.

The trigger needs github.enabled: true, a non-zero github.poll_interval, and a project whose origin remote is a github.com repository. When any of these is missing, or a listing fails, the poll status reads failing with the reason instead of going quiet.

Trusted events and allowed_actors

Some events can be caused by anyone on a public repository, and some need rights there:

  Trusted: needs rights on the repository Untrusted: anyone can cause it
github_issues labeled, unlabeled, assigned (triage) opened, reopened, closed
github_prs merged (write) opened, ready_for_review, closed, review_requested

closed is untrusted because authors can close their own issues and pull requests. review_requested is untrusted because CODEOWNERS requests reviews automatically on a pull request a stranger opens.

A GitHub trigger that can match an untrusted event is refused at load unless it names allowed_actors. A trigger with no match.action can match every event, so it needs the list too. A match.action value that the source never synthesizes is refused as well.

allowed_actors matches the author of the issue or pull request, ignoring case. A state diff cannot see who applied a label or requested a review, so the author is the only identity there is. The list stops a stranger’s issue from starting work. It does not say who acted on it.

# {config_dir}/triggers/review-requested.yaml
id: review-requested
enabled: true
source:
  type: github_prs
  project: 1
match:
  action: review_requested
  reviewer: lezli01
allowed_actors: [lezli01, a-trusted-teammate]
action:
  type: create_task
  workflow: review
  title: 'Review #: '
  github_pull: ''
limits:
  max_per_hour: 10

allowed_actors is refused on command and http sources. Their events carry no identity vincent can verify: a command’s trust is whatever its script filters, and an http source’s is its signature.

http: accept a signed push

source:
  type: http
  project: 1
  signature:
    scheme: github_hmac_sha256
    secret_env: VINCENT_CI_WEBHOOK_SECRET

An http source is never polled and has no seed. Events are pushed to POST /v1/triggers/{id}/events, and a push must carry both:

  • the daemon’s bearer token, like every other API call, and
  • an X-Hub-Signature-256 header: sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the secret in the daemon’s environment variable named by secret_env. The secret is read when the push arrives and is never written to the file.

github_hmac_sha256 is the only scheme. The body may be up to 4 MiB and must be a JSON object. Its event id is the body’s string id, or else the X-GitHub-Delivery header.

# TOKEN holds the contents of {data_dir}/token; `vincent daemon status` prints the port.
body='{"id":"build-1234","result":"FAILURE","branch":"vincent/12-fix-login"}'
sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$VINCENT_CI_WEBHOOK_SECRET" | sed 's/^.* //')"
curl -sS -X POST "http://127.0.0.1:7777/v1/triggers/ci-push/events" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Hub-Signature-256: $sig" \
  --data-binary "$body"
Status Why
200 Judged. The body is the delivery: the judgement plus delivery_id, task_id and detail.
400 The trigger’s source is not type: http. Or the body is not a JSON object, or it has no string id and no X-GitHub-Delivery header.
401 The bearer token is missing or wrong. Or the signature is missing or wrong, or the secret variable is unset in the daemon’s environment (these last three look the same on purpose). No ledger row is written.
404 No trigger has that id.
409 The trigger is not armed: disabled, triggers.enabled off, or a file that does not validate. details.reason says which.
413 The body is over 4 MiB.

A single push has no catch-up cap. Pushes to one daemon are judged one at a time, so two deliveries of the same key cannot both fire.

Because the route needs the bearer token, a GitHub.com webhook sent through a bare tunnel cannot deliver. GitHub cannot add the header. What does work is a sender on the daemon’s machine that can add it:

  • a job on a self-hosted runner on that machine, which posts the event with the token and a signature it computes, or
  • a relay on the same machine that receives GitHub’s webhook from a tunnel and adds the bearer token before forwarding it.

The route is not an MCP tool, because an agent that could inject events could start agents.

schedule: fire on a clock

source:
  type: schedule
  project: 1
  cron: "0 9 * * 1-5"        # or: every: 6h — exactly one of the two
  timezone: Europe/Budapest  # optional; default is the daemon host's zone

A schedule source is the clock rather than an outside system: it is what to use for work no event announces — a nightly dependency sweep, a weekday morning report. Everything after the source is the same as every other trigger: match:, if:, dedupe_key:, all four actions, the propose gate, the restricted clamp and limits.max_per_hour.

Set exactly one of cron: and every:. Both, or neither, is refused when the file loads. poll_interval:, command:, signature: and allowed_actors are refused too: there is nothing to poll, run, sign or attribute.

The cron: grammar is five space-separated fields and nothing more:

Field Range
minute 0-59
hour 0-23
day of month 1-31
month 1-12
day of week 0-7 — both 0 and 7 are Sunday

Each field is a *, a single value, a range a-b, a comma list of either, or any of those followed by a /n step. There are no @daily-style descriptors, no seconds field and no L or # extensions — the grammar the form’s help describes is exactly the grammar the daemon parses. An expression no calendar satisfies, such as 0 0 31 4 *, is refused when the file loads.

cron: "0 9 * * 1-5"       # 09:00, Monday to Friday
cron: "*/15 9-17 * * *"   # every quarter hour between 09:00 and 17:59
cron: "0 3 1 * *"         # 03:00 on the first of the month
cron: "0 9 1 * 1"         # the first of the month AND every Monday — see below

With * in one of the two day fields, the other decides. With both restricted, an occurrence matches either — which is what crontab(5) has always done, and why the last line above fires on Mondays as well as on the first.

every: is a duration of at least 1s, counted from the moment the trigger was enabled rather than from a wall-clock boundary. every: 6h on a trigger enabled at 10:17 fires at 16:17, 22:17, and so on — which is the only honest answer for every: 90m.

timezone: is an IANA name. An unknown one is refused when the file loads; it never falls back to UTC quietly. Absent, the daemon host’s zone is used.

Arming anchors the clock, and re-arming anchors it again. Enabling a schedule records that moment and fires nothing: the first occurrence it ever fires is one that falls after your keypress. Disabling and re-enabling it — or turning triggers.enabled off and on — anchors afresh, so nothing fires for the period it was off. A daemon stop, a suspend or a reboot is none of those: the anchor survives, so a schedule that came due while the machine was asleep fires when the daemon is back.

An overdue schedule fires once. A weekend of downtime produces one task, not forty: the next tick fires the last occurrence that passed and moves on.

Daylight saving. An occurrence inside the hour a spring-forward jump skips fires once, at the first real instant after the jump. An occurrence inside the hour a fall-back repeats fires once, not twice. every: is a duration and is affected by neither.

.Event carries six keys. id and scheduled_at are the occurrence in UTC; weekday, hour, minute and date are the same instant in the schedule’s own zone, because an author who writes 0 9 * * 1-5 means their own Monday morning.

action:
  type: create_task
  workflow: dependency-sweep
  title: "Dependency sweep "

Because id is the occurrence, the default dedupe_key already makes two evaluations of one occurrence fire once — you do not need a template for it.

A schedule has no source to run once, so POST /v1/triggers/{id}/poll refuses it, and with it the triggers view’s X and the trigger_poll MCP tool. Use vincent trigger test with a synthetic event instead. And a scheduled follow_up or retry whose branch renders to a branch no unarchived task is on is silent by design: the ledger records refused, and nothing else happens.

Actions

create_task

action:
  type: create_task
  workflow: feature-pr
  title: ' '
  description: ''
  fields:
    ticket: ''

This replays POST /v1/tasks in source.project. title is required, and every key is a template over .Event:

  • workflow defaults to the project’s default workflow.
  • description and fields fill the task’s description and workflow fields.
  • github_issue or github_pull must render to a number (a leading # is allowed) or to nothing. They cannot both be set. They work as they do when you create a task from an issue or a pull request.

The task is created paused under on_fire: propose, and with its agent steps clamped restricted unless the file says permission: workflow. When the route refuses (for example, a workflow the project does not have), the delivery lands refused with the route’s error in its detail.

follow_up, retry and cancel: act on an existing task

action:
  type: follow_up
  target: branch
  branch: ''
  prompt: 'CI failed on this branch: . Fix the cause.'

A reaction acts on work that already exists. target: branch is required and is the only target. branch is a template, and the task it names is the unarchived task in source.project whose branch is exactly that name. If more than one task has it, the newest wins. When no task is on the branch, the delivery is refused and its detail names the branch that was looked for. A branch that renders empty is an error.

Action Replays prompt
follow_up POST /v1/tasks/{id}/follow_up Required: what the follow-up run is told.
retry POST /v1/tasks/{id}/retry Optional: replaces the failed step’s prompt.
cancel POST /v1/tasks/{id}/cancel Refused: a cancel runs nothing.

The route’s own rules still apply. An action the task’s state does not allow, such as a cancel of a task that is already done, gets the route’s own refusal (here a 409) and lands refused, with the error in its detail. The ledger’s task_id is the task that was acted on.

A reaction refuses workflow, title, description, fields, github_issue, github_pull, permission and limits.max_task_cost_usd. Each of those describes a task being created.

on_fire and permission

on_fire: propose is the default. A task that a trigger creates, retries or follows up lands paused, and nothing runs until you resume it. on_fire: create starts it straight away, with no keypress, and is always an explicit line in the file.

A cancel has no paused form, so a cancel trigger is refused at load unless it writes on_fire: create.

permission: restricted is the default for create_task. Every agent step of the created task runs in restricted mode, even a step whose own permission_mode says otherwise. permission: workflow lifts that clamp and runs the workflow as written, full-auto included.

The TUI asks before it writes enabled: true, on_fire: create or permission: workflow.

Dry runs

Two dry runs show what a trigger would do. Neither writes anything to vincent: no task, no ledger row, no cursor change, no poll status change and no event. Both work while the trigger or triggers.enabled is off.

Judge a sample event with vincent trigger test:

vincent trigger test label-to-task --event labeled.json

It sends the event to POST /v1/triggers/{id}/test. It prints each stage in pipeline order: the match, the if: verdict, the dedupe key and whether the ledger already has it, the target task, and the request the action would replay. Here fired means “would be replayed”. The command exits 1 when the outcome is error, so a pre-commit hook can use it.

Run the source once for real with POST /v1/triggers/{id}/poll. It runs the command, or makes the GitHub listing, and judges every event it gets back. A GitHub trigger that has not seeded yet has no snapshot to diff the listing against, so it judges nothing and events comes back empty. The answer holds seed (a real poll now would only seed), events (one judgement each), truncated (events past the cap of 20), refused (command output lines that were not events), the cursor the command printed, and error when the command or listing failed. A failing poll still answers 200. A trigger with no source to run once — http, which is pushed, and schedule, which is a clock — is a 400.

The command itself really runs, so whatever it does outside vincent still happens.

POST /v1/triggers/validate checks a document without writing it. The reads, validate and both dry runs are also MCP tools. Creating, editing, deleting or enabling a trigger is not.

Watching what triggers do

The delivery ledger records every judged event, one row each:

Outcome Meaning
fired The replayed route created or acted on a task.
seeded A command source’s seed poll after arming saw the event. Nothing fired, and the key counts as delivered. A GitHub source seeds its snapshot instead, and writes no rows.
deduped The dedupe key had already fired or been seeded.
filtered match:, allowed_actors or if: dropped it.
rate_limited Over limits.max_per_hour. Dropped, not queued.
refused The route answered with a 4xx, or a reaction found no task on the branch. detail holds the reason.
error A template did not render, or the route answered with a 5xx or was never reached.
superseded overrun: dropped it: skip found unfinished work, a newer held event coalesced past it, the group already held this event, a disarm discarded it, or the backlog hit its cap. detail says which.
queued A queue mode is holding it until its group empties. It gets a second row when it fires.

Read it with GET /v1/triggers/{id}/deliveries?limit= (newest first, up to 1000 rows, 100 by default). The ledger outlives the file and is pruned after 30 days.

Poll status is on GET /v1/triggers for each trigger: seeded, last_poll_at, ok, error and last_fire_at.

Events on the SSE stream:

  • trigger.fired, for every fired delivery. Payload {trigger_id, delivery_id, action}, with the event’s project_id and task_id set to the task created or acted on. No other outcome publishes.
  • trigger.poll_changed, on a trigger’s first poll after arming and whenever it goes from ok to failing or back, never on every poll. Payload {trigger_id, ok, error}.

The TUI’s triggers view, opened from the command palette, lists every trigger with its armed state and poll status. It creates and edits triggers, enables and disables them, shows the ledger and runs both dry runs. See Using the TUI.

Examples

A Jira status starts work unattended

# {config_dir}/triggers/jira-ready-for-dev.yaml
id: jira-ready-for-dev
enabled: true
source:
  type: command
  project: 1
  poll_interval: 5m
  command:
    - /usr/local/bin/vincent-jira-poll
    - --project=VIN
    - --status=Ready for Dev
action:
  type: create_task
  workflow: feature-pr
  title: ' '
  description: |
    Jira : 

    
  fields:
    ticket: ''
on_fire: create
limits:
  max_per_hour: 3
  max_task_cost_usd: 4.00

The poll script is yours, and it is the whole of the Jira integration. It searches Jira using $VINCENT_TRIGGER_CURSOR and prints one line per issue, with an id that includes the issue’s update time. It finishes with a cursor line. The Jira credential comes from the daemon’s environment. on_fire: create runs unattended, so pair it with a tight max_task_cost_usd and a workflow whose steps run in a container. That limit caps one task. If the workflow fans out, each lane is a task of its own, so also set max_tree_cost_usd in config.yaml to bound the task and its lanes together.

A red CI build follows up its task

# {config_dir}/triggers/ci-failure-follow-up.yaml
id: ci-failure-follow-up
enabled: true
source:
  type: command
  project: 1
  poll_interval: 2m
  command: [/usr/local/bin/vincent-jenkins-poll, --job=vincent-ci]
match:
  result: FAILURE
action:
  type: follow_up
  target: branch
  branch: ''
  prompt: |
    CI failed on this branch. Job  build #:
    

    Read the log, fix the cause, and push. If the failure is not in this
    branch's changes, say so and stop rather than guessing.
dedupe_key: 'jenkins::'
limits:
  max_per_hour: 4

The follow-up lands paused on the task whose branch CI built. A build on a branch that no vincent task owns lands refused. The build number is the dedupe key, so a poller that sees the same red build twice fires once.

GitHub Actions retries a blocked task over the ingress

# {config_dir}/triggers/gha-check-failed.yaml
id: gha-check-failed
enabled: true
source:
  type: http
  project: 1
  signature:
    scheme: github_hmac_sha256
    secret_env: VINCENT_GHA_WEBHOOK_SECRET
match:
  action: completed
  workflow_run.conclusion: failure
if: ''
action:
  type: retry
  target: branch
  branch: ''
dedupe_key: 'gha:'

A job on a self-hosted runner on the daemon’s machine posts the workflow_run payload with the bearer token, the signature and its run id as X-GitHub-Delivery. if: keeps branches vincent did not cut out, using len and slice because templates have no extra functions. The retry lands paused, and a task whose state the retry route refuses lands refused.

A merged pull request cancels its task

# {config_dir}/triggers/pr-merged-cancel.yaml
id: pr-merged-cancel
enabled: true
source:
  type: github_prs
  project: 1
match:
  action: merged
action:
  type: cancel
  target: branch
  branch: ''
on_fire: create

merged needs write access, so it is trusted and the file needs no allowed_actors. on_fire: create is required for a cancel. A task that is already done gets the route’s 409 and lands refused.

Letting an agent write triggers

An agent can author a trigger for you, but it can never arm one. Arming stays your change.

The vincent-triggers skill

The published vincent-triggers skill teaches an agent this page: the sources, the trusted-event rule, dedupe keys, limits, poll scripts and the ledger. It installs beside vincent-workflows:

vincent skills install
# or by hand:
npx skills add lezli01/vincent --skill vincent-triggers -g

The skill writes every trigger disarmed. It sets enabled: false, leaves out on_fire and permission, and always sets a dedupe_key and limits. On a source that can emit twice about one object it sets overrun: and a concurrency_key: naming that object, never cancel_previous, which destroys work. It changes a switch only when you ask for that exact change. For the workflow a trigger’s action.workflow names, it defers to vincent-workflows.

The create-trigger and update-triggers built-ins

Two built-in workflows use the skill against the task’s own project:

  • create-trigger writes one new trigger. Its trigger_id field becomes the trigger’s id: and its file name. The agent step may ask you questions. It then stages the file, and a command step installs it with vincent trigger apply. There is no approval gate, because the file cannot fire until you arm it.
  • update-triggers reviews every trigger whose source.project is the task’s project. It lists them, stages whole rewritten copies, and stops at a manual gate whose instructions show the proposal. Approving installs it with vincent trigger apply. Rejecting leaves every trigger untouched. The pass never changes a trigger’s id, file name, source.project, enabled, on_fire or permission, deletes no file, and never changes what a dedupe_key renders for events already delivered or what a concurrency_key renders while its group has work in flight.

Neither built-in can produce a cancel trigger, because a cancel loads only with on_fire: create. Write one by hand.

Proposals and vincent trigger apply

A built-in’s agent never writes {config_dir}/triggers/ itself. It stages files in {data_dir}/trigger-proposals/<task id>/, outside every repository, beside a manifest.json that maps each trigger id to the version vincent trigger ls --project <id> --json reported, or to "absent" for a new trigger. The directory is removed after a successful apply, and when the task is deleted.

vincent trigger apply --proposal <task id> --project <id> checks every staged file before it writes any. It refuses the whole proposal, and writes nothing, when a file:

  • does not validate;
  • names a different source.project than --project;
  • changed on disk since the proposal recorded its version, or appeared after being recorded as absent;
  • arms the trigger: enabled from off to true, on_fire to create, or permission to workflow.

An already-armed value may be kept, and disarming is allowed. There is no override flag. You arm a trigger in the TUI’s triggers view, which asks first, or in an editor. triggers.enabled lives in config.yaml, and apply never touches it. See the CLI reference for validate, ls and apply.

Where poll scripts live

By convention a type: command trigger’s script goes in {config_dir}/trigger-scripts/. vincent does not enforce the location. It sits beside triggers/, never inside it, and never in a repository. On POSIX, make the directory and each script owner-only (0700). Name the script by absolute path. The argv runs with no shell, so on Windows write command: [pwsh, -NoProfile, -File, <absolute path>.ps1]. Credentials come from the daemon’s environment, never from the script or the trigger file.

Security

A trigger lets someone else start an agent that runs as you. Agents run full-auto unless something narrows them, and a trigger’s text comes from outside. An issue body, a pull-request title or a CI log reaches .Event, and whatever the file renders into a title, description, field or prompt reaches the agent. That is prompt injection from anywhere a stranger can type.

The defaults are the protection. Keep them unless you have a reason:

  • on_fire: propose, so you read each task before resume.
  • permission: restricted, so agent steps run in restricted mode.
  • allowed_actors on any GitHub trigger that can match an untrusted event.
  • limits.max_per_hour and limits.max_task_cost_usd.
  • For unattended work, an action.workflow whose steps run in a container.

Render as little of .Event as the task needs. See Event triggers let someone else start an agent for the full posture.

See also