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:
- its own
enabled: true, and triggers.enabledinconfig.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 turningtriggers.enabledoff, 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:
match:Every key must hold, or the event isfiltered.allowed_actors, on a GitHub source. An author the list does not name isfiltered.if:A render offalseisfiltered. A render that is neithertruenorfalseis anerror.- Dedupe. A key that already
firedor wasseededisdeduped. - Rate limit. Once
limits.max_per_hourdeliveries have fired in the trailing hour, the event israte_limitedand dropped, not queued. - Render. The action’s templates are rendered. A render failure is an
error. - Target. A reaction finds its task by branch. No task on that branch is
refused. - Overrun. If the trigger sets
overrun:, the group itsconcurrency_key:names is checked for unfinished work.skiprecords the eventsuperseded; a queue mode records itqueuedand holds it;cancel_previouscancels what it found and carries on.parallel, the default, never reaches this step. - Replay. The request goes to the route. A 2xx answer is
fired, a 4xx (such as the state machine’s409) isrefused, and anything else is anerror.
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 stringid, 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.IssuehasNumber,Title,Body,URL,Author,State,Labels,AssigneesandUpdatedAt..Event.PullhasNumber,Title,Body,URL,Author,State,HeadRef,BaseRef,Draft,Merged,Labels,RequestedReviewersandUpdatedAt.
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-256header:sha256=followed by the hex HMAC-SHA256 of the raw body, keyed with the secret in the daemon’s environment variable named bysecret_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:
workflowdefaults to the project’s default workflow.descriptionandfieldsfill the task’s description and workflow fields.github_issueorgithub_pullmust 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 everyfireddelivery. Payload{trigger_id, delivery_id, action}, with the event’sproject_idandtask_idset 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-triggerwrites one new trigger. Itstrigger_idfield becomes the trigger’sid:and its file name. The agent step may ask you questions. It then stages the file, and a command step installs it withvincent trigger apply. There is no approval gate, because the file cannot fire until you arm it.update-triggersreviews every trigger whosesource.projectis 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 withvincent trigger apply. Rejecting leaves every trigger untouched. The pass never changes a trigger’sid, file name,source.project,enabled,on_fireorpermission, deletes no file, and never changes what adedupe_keyrenders for events already delivered or what aconcurrency_keyrenders 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.projectthan--project; - changed on disk since the proposal recorded its version, or appeared after being recorded as absent;
- arms the trigger:
enabledfrom off totrue,on_firetocreate, orpermissiontoworkflow.
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 beforeresume.permission: restricted, so agent steps run in restricted mode.allowed_actorson any GitHub trigger that can match an untrusted event.limits.max_per_hourandlimits.max_task_cost_usd.- For unattended work, an
action.workflowwhose 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
- Configuration:
triggers.enabled, andgithubfor the tick GitHub sources ride. - CLI:
vincent trigger validate,ls,applyandtest. - HTTP API: the
/v1/triggersroutes and the event stream. - Using the TUI: the triggers view.
- Security model.