vincent-triggers
Skill28 starsChanged 3 months agoMITView on GitHub
Create, edit, review, arm, and debug vincent event triggers, the YAML under {config_dir}/triggers and the poll scripts they run. Use for trigger sources (issues, command, github_issues, github_prs, http, schedule), match and if filters, dedupe keys, limits, on_fire, permission, arming, dry runs, the delivery ledger, or trigger validation errors. Do not use for vincent workflows, including the workflow a trigger's action.workflow names (use vincent-workflows), or for GitHub Actions.
In this repository28 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- ask-mattSkill
- codebase-designSkill
- code-reviewSkill
- diagnosing-bugsSkill
- domain-modelingSkill
- grillingSkill
- grill-meSkill
- grill-with-docsSkill
- handoffSkill
- implementSkill
- improve-codebase-architectureSkill
- prototypeSkill
- researchSkill
- resolving-merge-conflictsSkill
- setup-matt-pocock-skillsSkill
- tddSkill
- teachSkill
- to-questionnaireSkill
- to-specSkill
- to-ticketsSkill
- triageSkill
- wait-whatSkill
- wayfinderSkill
- wizardSkill
- writing-for-agentsSkill
- vincent-workflowsSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
18 rules. About 0 are common, 7 are distinctive, and 11 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈7
Distinctive · estimate
This author's own choices. Worth a look.
11
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 27
Every trigger you write starts disarmed and bounded:
- Distinctive: line 67
No secrets: Never put a secret in a trigger file, a poll script or any argv.
- Distinctive: line 72
Look before you ask:
- Very specific: line 79
If it is missing, say so. Writing it is the vincent-workflows skill's job.
- Very specific: line 97
Read references/poll-scripts.md for any type: command source.
- Very specific: line 102
Run vincent version when the binary is available, and keep the output.
- Very specific: line 127
Use closed/reopened when the close reason or state matters (.Event.reason).
- Very specific: line 173
Only four events are trusted, because an outsider cannot cause them: issue labeled, unlabeled and assigned, and pull request merged.
- Distinctive: line 182
Prefer a label as the signal to start.
- Very specific: line 241
Use issue to link the task to a vincent issue: a template rendering the issue id, or nothing.
- Very specific: line 244
Use github_pull to link a GitHub pull request, rather than parsing a number into the title.
- Very specific: line 246
Use merge_back: {on_conflict: block|agent} beside issue for per-issue work that should not queue behind the issue's main line: the task is …
- Very specific: line 354
Read the ledger before you believe a trigger did nothing: superseded is an event overrun dropped, queued one it is holding.
- Very specific: line 378
Put the script in {config_dir}/trigger-scripts/.
- Very specific: line 381
Keep it beside triggers/, never inside it, and never in a repository.
- Distinctive: line 394
Read the reference before writing one.
- Distinctive: line 451
When a trigger misbehaves, work in this order:
- Distinctive: line 495
no secret in any argv,
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
vincent Triggers
A trigger lets something other than a person start vincent work. An event on one of the project's issues, from a poll script, from GitHub, or from a signed push can create a task, or follow up, retry or cancel the task on a branch. Nobody presses a key, so design the smallest trigger that is safe to leave on, and leave turning it on to a human.
This skill covers four jobs:
- authoring a trigger,
- writing its poll script,
- arming and debugging it,
- migrating or reviewing existing triggers.
The safety rule
Every trigger you write starts disarmed and bounded:
enabled: false.- No
on_fireline, so a fired event's task landspaused(propose). - No
permissionline, so a created task's agent steps runrestricted. - An explicit
dedupe_key. limits.max_per_hour, pluslimits.max_task_cost_usdon acreate_taskorstart_handoff.
Five switches arm a trigger or widen what it may do:
enabled: trueon_fire: createpermission: workflow- a
start_handoffaction'sgrantgaining a capability it did not have (narrowing or removing one is not arming) - the global
triggers.enabledinconfig.yaml
Change one only when the user asks for that exact change. Never flip one to make something work, and say what it does before you write it. When they stay off, end by naming what a human must do to arm the trigger.
Consequences of the rule:
- Cancel triggers. A
cancelaction is refused at load unless it writeson_fire: create, because a cancel cannot be held for approval. Write one only when the user asks for exactly that. Otherwise explain this and write nothing. - No
container:. A trigger has nocontainer:key, and a file with one is refused. Foron_fire: create, recommend anaction.workflowwhose steps run in a container, plus a tightlimits.max_task_cost_usd. That limit caps one task, not a tree. If the workflow fans out, also recommendmax_tree_cost_usdinconfig.yaml, which caps the task and all its lanes together. It is a config key, not a trigger key, so never write it into the trigger file. - Global only. Triggers are global. There is no
.vincent/triggers/, so never write a trigger or its poll script into a repository. - Untrusted event text. Event text comes from outside. An issue body or CI
log rendered into a title, description, field or prompt reaches an agent.
Render as little of
.Eventas the task needs. - No secrets. Never put a secret in a trigger file, a poll script or any argv. Credentials come from the daemon's inherited environment.
Gather only decisions that matter
Look before you ask:
- Project.
source.projectis the project's numeric id, not its name. Find it withvincent project lsor theproject_listMCP tool. - Existing triggers.
vincent trigger ls --project <id>lists that project's triggers. Never silently overwrite an id that already exists. - Workflow.
vincent workflow lsshows whetheraction.workflowexists. If it is missing, say so. Writing it is thevincent-workflowsskill's job.
Then ask only what changes the YAML:
- Which system and event? Should it create a task, or follow up, retry or cancel the task on the event's branch?
- What makes two events "the same"? That becomes the
dedupe_key. - On issues or GitHub, which events, and whose issues or pull requests may start work? Should an agent's own change be able to fire it?
- What hourly rate is plausible, and what should one task cost at most?
- Which platform does the daemon run on, and where do credentials come from?
Read the references
Inside a vincent source checkout, read its docs/guides/triggers.md. It is
newer and more authoritative than a bundled snapshot. Otherwise use
references/trigger-schema.md.
Read references/poll-scripts.md for any
type: command source. Read
references/debugging.md when a trigger does not do
what was expected.
Run vincent version when the binary is available, and keep the output. That
binary's vincent trigger validate gives the compatibility verdict. If it
rejects a feature the reference describes, report the version mismatch and ask
before changing the design.
Choose the source
issuesreads the project's own issues: local ones, and issues the project imports from GitHub or Jira. Prefer it for anything that starts from an issue..Event.Issue.Source.Provider(github,jira, or empty for a local issue) tells them apart.- It takes no
commandand nopoll_interval; it is woken by each issue change the daemon commits, and works on a project with no GitHub remote. - Events:
opened,closed,reopened,labeled,unlabeled, and the opt-inlane_changedandhandoff_changed. There is noassigned(a vincent issue has no assignee), and it is refused. Edits, comments and deletes fire nothing. .Event.bysays who made the change:human,agent(a task's agent over MCP),sync(the issue's tracker, through the importer) or, onlane_changedonly,task(a root task's start, finish, delete or restore moved it).lane_changedfires when the issue's board lane moves (open,in_progress,hand_off,done)..Event.laneis the lane moved to,.Event.from_lanethe lane moved from, and.Event.task_idthe task that moved it — present only whenbyistask, so guard it with{{ if eq .Event.by "task" }}. It is opt-in: only a trigger whosematch.actionnames it sees it; one with nomatch.actionnever does. Useclosed/reopenedwhen the close reason or state matters (.Event.reason); uselane_changedwhen the board move matters —lane: hand_off, orfrom_lane: hand_off, lane: done.match: {by: human}drops task-caused moves; a hand-off trigger writesby: task.handoff_changedfires when a handoff item's outcome moves (delivered,returned,waiting,released, or empty for an unresolved item), and is opt-in the same way..Event.outcomeis the outcome moved to,.Event.from_outcomethe one moved from (empty on an item's first resolution),.Event.handoff_idthe handoff and.Event.task_idits task..Event.byis the item's actor:handoff,human,agentorsettleare local;sync— a tracker close resolving a waiting item — is not, so a trigger that can match it from sync needsallowed_actors, ascloseddoes.- Hand-off echo loops. A
lane: hand_offtrigger whose task is created for the same issue moves it back toin_progress, and tohand_offagain when that task finishes. Setdedupe_key: '{{ .Event.issue_id }}:{{ .Event.lane }}'to fire once per issue per lane, and keeplimits.max_per_houras the backstop. - Echo loops. A trigger whose task's agent labels or closes issues can
re-fire itself. Write
match: {by: human}unless the user wants agent changes to fire, and keeplimits.max_per_houras the backstop. - Trust.
humanandagentchanges are local and trusted. Asyncchange arrives from the issue's tracker (GitHub or Jira), whose author someone outside this machine controls, so it followsgithub_issues' rule:labeledandunlabeledare trusted from GitHub (a judgement made against GitHub's roles, where labelling needs triage) and untrusted from Jira, where anyone who may edit the issue may label it — a Jira label change bysyncgoes throughallowed_actors, and on a trigger that names none it never fires;opened,closed,reopened,lane_changedandhandoff_changed(which sync delivers only for a tracker close or reopen) needallowed_actors, matched against the issue's author. A trigger that can match one of those from sync is refused withoutallowed_actors— unless itsmatch.byleavessyncout.allowed_actorsnever refuses a local person's change. - Link the task with
action.issue: '{{ .Event.issue_id }}'.
- It takes no
github_issuesis deprecated; useissueswhen the project imports its GitHub issues. It keeps firing, andvincent trigger applyandvincent trigger validateprint a warning for it. Its events carry.Event.IssueID, the imported vincent issue's id (empty when the project has not imported that issue), soissue: '{{ .Event.IssueID }}'links its task.github_issuesorgithub_prswatch repository state.- They take no
commandand nopoll_interval. An interval is refused, because they run ongithub.poll_intervalinconfig.yaml. - Only four events are trusted, because an outsider cannot cause them:
issue
labeled,unlabeledandassigned, and pull requestmerged. - A trigger is refused unless it names
allowed_actorswhen itsmatch.actioncould match any other event. That includes a trigger with nomatch.actionat all. allowed_actorsmatches the issue's or pull request's author, ignoring case. It does not match whoever acted.review_requestedis untrusted, because CODEOWNERS requests reviews on a stranger's pull request.- Prefer a label as the signal to start.
- They take no
commandcovers any other system with an API or CLI: a poll script run on apoll_intervalof at least1s, in practice minutes.scheduleis the clock, for work no outside event announces: a nightly sweep, a weekday morning report.- Set exactly one of
cron(five fields: minute, hour, day of month, month, day of week) orevery(a duration of at least1s). Both set, or neither, is refused. timezoneis an IANA name such asEurope/Budapest; absent means the daemon host's zone. An unknown name is refused, never silently UTC.poll_interval,command,signatureandallowed_actorsare all refused: there is nothing to poll, run, sign or attribute..Eventcarriesidandscheduled_at(the occurrence, UTC), plusweekday,hour,minuteanddatein the schedule's own zone.- Re-arming resets the clock. Enabling anchors it at that moment and
fires nothing. A daemon restart, a suspend or a reboot keeps the anchor, so
an occurrence missed while the machine slept fires once on the next
tick; but disabling and re-enabling — or toggling
triggers.enabledoff and on — anchors afresh and fires nothing.
- Set exactly one of
httpfits a sender on the daemon's machine that can push.- It requires
signature.scheme: github_hmac_sha256andsignature.secret_env, which names a variable in the daemon's environment. The secret itself never goes in the file. - The sender also needs the daemon's bearer token, so a GitHub.com webhook sent through a bare tunnel cannot deliver.
- It requires
allowed_actors is refused on command, http and schedule sources, whose
events carry no identity vincent can verify.
The headline shape — a label a person puts on an issue starts a task linked to it:
id: triage-ready
enabled: false
source:
type: issues
project: 3
match:
action: labeled
labels: ready
by: human
action:
type: create_task
workflow: implement
title: '{{ .Event.Issue.Title }}'
issue: '{{ .Event.issue_id }}'
dedupe_key: 'issue:{{ .Event.issue_id }}:ready'
limits:
max_per_hour: 5
max_task_cost_usd: 5Choose the action
create_task:titleis required.workflowdefaults to the project's default workflow.descriptionandfieldsare optional.- Use
issueto link the task to a vincent issue: a template rendering the issue id, or nothing. The task is created from the issue as a person's would be.issuecannot be combined withgithub_pull. - Use
github_pullto link a GitHub pull request, rather than parsing a number into the title. - Use
merge_back: {on_conflict: block|agent}besideissuefor per-issue work that should not queue behind the issue's main line: the task is a side task, in its own worktree cut from the issue's main branch and merged back into it when done.block(the default when empty) stops for a human on a conflict, andagenttries a resolver first. It needsissue. A side task needs a main branch to fork from, so a delivery on an issue with no main task yet is refused with the route's 400 ("create a main task first"); use it only where the issue is already being worked. Withmerge_back, anissuethat renders to nothing is refused as well ("merge_back requires issue_id"), so don't share one such trigger across events with and without an issue. - There is no
github_issue. It was removed, and a file that still carries it fails validation and stops firing until it is edited; its cursor is kept, so nothing is re-seeded. Replace it withissue:, on anissuessource ('{{ .Event.issue_id }}') or agithub_issuesone ('{{ .Event.IssueID }}'). permissionisrestricted(the default) orworkflow.
start_handoffstarts a handoff, asvincent handoff startwould: one task, running a workflow that declaresrole: handoff, over issues in the project'shand_offlane.workflowis required;title,descriptionandfieldsare as oncreate_task.- Exactly one of
issue_idsandselect.issue_idsis a list of templates, each rendering one issue id (a leading#is allowed) or nothing; empty ones are dropped, and when all are empty the delivery is refused rather than falling back to a selection.selectis literal:lane(onlyhand_off),delivery(change,replyornone, one or a list),kind,labels,max(at least 1) andorder(onlypriority). A selection that finds nothing is refused (nothing_to_hand_off), which does not spend the dedupe key. grantlists the forge-write capabilities the handoff's agent may use:issue.comment,issue.close,pull.create,pull.comment,pull.merge,pull.close,checks.rerun. Adding one is arming: the TUI asks first andvincent trigger applyrefuses a staged file that adds one. Never add or widen a grant yourself; name in your summary the capabilities a human must add.on_fire,permissionandlimits.max_task_cost_usdwork as oncreate_task. It refusesissue,github_pull,merge_back,target,branchandprompt; every other action refusesissue_ids,selectandgrant.- The hand-off echo loop above does not apply: a handoff's task is a root
task with no issue, so it never moves an issue back to
in_progress. - Batching. A nightly train is a
schedulesource withselect. An event-driven batch is anissuessource matchinglane_changedwithlane: hand_off, plusoverrun: queue_coalesceand a fixedconcurrency_key: while the handoff task runs, later events are held, and when it settles the newest fires once and selects everything that accumulated.
follow_up,retryandcancelact on an existing task:target: branchis required, with abranchtemplate naming the task's branch.follow_uprequiresprompt,retrymay override the failed step's prompt, andcancelrefuses one.- Each refuses
permission,workflow,title,description,fields,issue,github_pull,merge_back,limits.max_task_cost_usd,issue_ids,selectandgrant.
Write the templates
if,dedupe_keyand every action string are Gotext/templates over one root,.Event. They get the standard builtins (and,eq,len,slice,index,printfand so on) and no extra functions, so a prefix test useslenandslice.- Rendering uses
missingkey=error. Put a cheapmatch:in front ofif:so sparse events are dropped before a template reads a key they lack. match:compares values as text. A list in the file means "any of these", and a list in the event means "contains".if:must render exactlytrueorfalse.- A JSON number from a poll script or a pushed body arrives as a float, so
render it with
{{ printf "%.0f" .Event.workflow_run.id }}. Numbers in GitHub events are already integers. .Eventexists only in trigger templates, never in a workflow step. Anything the workflow needs goes through the rendered title, description or fields.dedupe_keydefines what "once" means. When absent it is the event'sid, and a GitHub event's id includes the update time, so a relabel counts as a new event.- Changing an existing
dedupe_keymust not change what it renders for events already delivered. A new rendering misses the ledger and fires those events again.
Overrun: the previous run is still going
overrun: says what to do with an event whose group already has unfinished
work. Absent it is parallel, which is no check at all — every event fires,
however many of this trigger's tasks are mid-flight. Set it on any source that
emits repeatedly about the same object.
| value | behaviour |
parallel | the default: no check |
skip | record the event and drop it; the work in flight stands |
cancel_previous | cancel every in-flight task in the group, then fire |
queue_coalesce | hold it; when the group empties, fire the newest held event |
queue_serial | hold it; when the group empties, fire the oldest, and repeat |
concurrency_key: names the group, as a template over .Event. Absent it is
the trigger id — one at a time per trigger — and, for a reaction, the task its
branch: resolved to.
- It is not
dedupe_key. On the case this exists for, the dedupe key is per modification ({{ .Event.id }}) and the group is the object ({{ .Event.ticket }}). Setting them to the same thing disarms the feature. - It is refused without an
overrun:other thanparallel: a group nothing consults is a control that does not control. - Changing it on a live trigger re-groups events already in flight, the
same standing warning
dedupe_keycarries.
Read the ledger before you believe a trigger did nothing: superseded is an
event overrun dropped, queued one it is holding.
Two things to say out loud when you write one:
- A
pausedtask holds its group, andon_fire: proposecreates every task paused. Withskip, one unreviewed proposal holds its group until a human admits or archives it. That is intended — a second proposal for the same object is noise — but withproposeandskiptogether, a trigger that looks dead is usually one waiting on a proposal nobody looked at. So doblocked,awaiting_gateandawaiting_children; onlydone,abortedandarchivedrelease a group. cancel_previousdestroys work. An inbound event kills an agent mid-run. The cancelled task keeps its branch and worktree and the delivery records which task it superseded, but nothing un-cancels it. On a GitHub source, nameallowed_actors— it is what stands between a stranger's event and a cancelled run.
A queue mode holds at most 100 events per trigger; at the cap the oldest is
dropped and recorded superseded. Held events survive a daemon restart and are
discarded when the trigger is disarmed.
Poll scripts
Put the script in {config_dir}/trigger-scripts/. This is a convention; vincent
does not enforce it.
- Keep it beside
triggers/, never inside it, and never in a repository. - On POSIX, make the directory and the script owner-only (
0700), and name the script by absolute path. command:runs directly with no shell. On Windows, writecommand: [pwsh, -NoProfile, -File, <absolute path>.ps1].
The script's contract:
- It prints one JSON object per stdout line, each with a string
id. - It may print a last line
{"cursor": "..."}. The next run receives that value in$VINCENT_TRIGGER_CURSOR. - It exits non-zero on any failure.
Read the reference before writing one.
Author and validate
- Write the file. It goes at
{config_dir}/triggers/<id>.yaml, andidmust equal the file name without.yaml: lowercase letters, digits,-,_and..vincent doctornames the config dir, andtrigger_listreports the triggers dir.- On POSIX, keep the file
0600, as the daemon's own writer does. - The daemon reloads the file on save.
- Validate. Run
vincent trigger validate <file>.- It needs no daemon, and gives the same verdict as
POST /v1/triggers/validateplus the check thatidmatches the file name. - It exits 0 when the file is valid and 1 when it is not.
- Fix every error. Decoding is strict, and a key the chosen type does not take is an error, never ignored.
- It needs no daemon, and gives the same verdict as
- Dry-run. Run
vincent trigger test <id> --event event.json.- It judges a sample event through the real pipeline and writes nothing.
- It needs the daemon, the trigger loaded under that id, and a fixture shaped like the source's real events.
- For a command source,
trigger_pollruns the real command once and judges its output without firing.
- No binary? Say validation was not run. Never substitute a generic YAML parser.
Report back:
- the file path,
- the
vincent versionoutput and the validate and dry-run results, - the source, action and project id,
- the trust decision: which events, and
allowed_actors, - what the dedupe key means, and the limits,
- which credentials the daemon's environment must hold,
- each switch still off, and how a human turns it on.
Arm and debug
A trigger is armed when its file validates, it says enabled: true, and
triggers.enabled is on.
- Arming seeds. The first poll records what the source already shows and
fires nothing. A
scheduleanchors its clock at that moment instead, and the first occurrence it ever fires is one that falls after the keypress. - Disarming drops the cursor, so arming again seeds again — and a
scheduleanchors afresh, which is why a disable/enable cycle fires nothing even when occurrences went by in between. - A save that does not validate keeps the cursor.
Humans arm triggers in the TUI's Triggers view, which asks before enabling one, or in an editor. No MCP tool writes or enables a trigger.
The built-in create-trigger and update-triggers workflows stage files for
vincent trigger apply --proposal <task_id> --project <id> to install. Apply
refuses any change that arms a trigger.
When a trigger misbehaves, work in this order:
trigger_listshows whether each trigger is valid,armed, itsdisarmed_reason, and its poll health.trigger_deliveriesshows each event'soutcome(seeded,fired,deduped,filtered,rate_limited,refused,error,supersededorqueued) anddetail.- Reproduce with
vincent trigger testortrigger_poll. Ascheduleand anhttpsource have no poll, sotrigger_pollrefuses both: supply a synthetic event tovincent trigger testinstead.
A scheduled reaction — follow_up or retry whose branch renders from the
occurrence — is silent by design when no unarchived task is on that branch:
the ledger records refused and nothing else happens. Check the deliveries
before concluding the clock did not strike.
The debugging reference maps each outcome to its causes.
Migrate and review
List a project's triggers with vincent trigger ls --project <id> --json. It
gives each file's id, version, validity, enabled, on_fire, permission and
errors.
Report correctness and safety findings first, then check each trigger for:
match:in front ofif:,- an explicit
dedupe_key, overrun:on any source that can emit twice about one object, with aconcurrency_key:naming that object and not repeatingdedupe_key,limits.max_per_hour, andmax_task_cost_usdon acreate_taskorstart_handoff,issueorgithub_pullprefill,- no
poll_intervalon anissuesor GitHub source, allowed_actorswherever an untrusted GitHub event can match, including asyncone onissues,match: {by: human}on anissuestrigger whose own task's agent writes issues, unless agent changes are meant to fire,- a per-issue-per-lane
dedupe_keyon alane_changedtrigger whose task is created for the same issue, - a
github_issuestrigger on a project that imports its issues, which should move toissues, - reactions that carry none of the keys they refuse,
httpsignatures that usesecret_env,- no secret in any argv,
- poll scripts that follow the contract,
printf "%.0f"on keys built from JSON numbers,- nothing that relies on
.Eventin a step template.
Older drafts need these corrections:
- a project name becomes the project's id,
- a
container:block becomes a containerized workflow, poll_intervalcomes off GitHub andissuessources,match.action: assignedcomes off anissuessource,.Event.Actorand.Event.Labeldo not exist, so remove them,action.github_issuebecomesaction.issue, and agithub_issuessource carrying it moves toissueswhen the project imports its issues.
Some things must stay as they are:
id, the file name andsource.project.enabled,on_fireandpermission, unless the user asks to change them.- What
dedupe_keyrenders for events already delivered. - What
concurrency_keyrenders, while a group has work in flight.
A trigger that is already right stays byte for byte.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

