Skip to content

Workflow manifest

A workflow lives in <profile>/workflows/<name>/workflow.toml, beside the intents, and is loaded and checked the same way. The bundled ones are listed in The sweep and workflows. A name is a lower-case letter, then up to 39 lower-case letters, digits or -, and may not repeat a bundled workflow; the configuration’s workflows.<name> overrides a bundled one instead.

summary = "Close a session whose pull request is merged or closed"
provider = "session"
when = 'session.pr_state in ["MERGED", "CLOSED"]'
order = 11
[[step]]
use = "session.close"
[limits]
per_run = 10
per_day = 100

The top-level keys come before the first table, and a step’s arguments are sibling keys of use:

[[step]]
use = "turn.start"
intent = "review"
stage = "follow-up"

The loader refuses any other key, naming a close one when one exists. Without [trigger] a workflow is planned on every sweep; on plans it from a durable cursor of events, so an event seen while the daemon was down is handled once, and schedule on a cron in local time. The configuration’s workflows.<name> overlays observe, enabled, only_when and [gates.approval], field by field: mode and window replace those fields and rule replaces the rules ([] clears them); over a workflow whose [gates.approval] names no draft gate, the config’s block overlays the intent’s for its turns’ drafts. The config loads what holds alone; a block that fails once merged is named by doctor.

KeyKindDefaultMeaning
summarystring, localizedOne line, what the workflow does; its plan items’ description. Required.
providerone of github, session, slack, task, workspaceThe provider of the units it matches. Required.
whenpredicateThe match condition, over the unit’s facts, the trigger’s and config.*. Required.
orderinteger100Evaluation order, lowest first.
keylist of factsThe facts forming the idempotency key, by default <slot>.fingerprint: a key that ran or was dismissed is not planned again. A workflow with trigger.on may add trigger.event.id, so each event plans its own run.
markstable by nameMark name to fact, stored when a run is done or dismissed, which handled() reads.
marks.&lt;name&gt;factThe fact whose value the mark stores.
KeyKindDefaultMeaning
triggertableWhat plans the workflow: on or schedule; absent, every sweep.
trigger.onlist of stringsThe event kinds the workflow reacts to: the loop’s, the plugins’, task.woke (a task’s wake matched) and verb_<verb>, a verb of tasks.toml run on a task by anyone, a workflow included; the last two with provider task.
trigger.schedulestringA cron schedule, in local time.
trigger.actorslist of person, workflow, agent, pluginWith on, the actors whose verb events plan it (person, workflow, agent, plugin); every actor when absent.
trigger.depthinteger3With on, the longest chain of workflows triggering each other the workflow may close: an event a chain of that many workflows caused plans it no more, and a workflow already in an event’s chain never runs on it. 1 to 5.
observeboolfalsePlans items pending and never runs them by itself; a person applies an item by hand.
enabledbooltruefalse ships the workflow off: it plans nothing until workflows.<name>.enabled = true in the config turns it on.
limitstableHow many runs the workflow may take.
limits.per_runinteger0Runs per evaluation; 0 is no limit.
limits.per_dayinteger0Runs per local day; 0 is no limit.
limits.per_unit_per_dayinteger0Runs on one unit per local day; 0 is no limit.
limits.cooldowndurationThe least time between runs on one unit.
KeyKindDefaultMeaning
step[]tableA step the run takes, in order. Required: at least one.
step[].useone of session.close, workspace.remove, workspace.unregister, turn.dismiss, answer.refuse, session.mark_dormant, session.close_orphan, turn.start, draft.propose, classify, notify.self, task.verb, gate.open, items.resolve, task.setThe step.
step[].&lt;name&gt;string or listThe step’s arguments.
KeyKindDefaultMeaning
askstring, localizedWhat the plan gate of a run asks, a text template over .Unit (the unit as people read it) and .Facts (the unit’s facts by name, such as {{index .Facts "session.name"}}); its summary when absent.
gates.drafts_waitboolfalseEvery draft the workflow proposes waits for a person: no approval rule of its own or of the configuration releases it.
gates.withdrawtableWhen an open gate the workflow’s gate.open opened on a unit no longer stands: the engine supersedes it at the start of a sweep and records gate_withdrawn on its task with the reason, so a step whose subject is gone never waits for you to dismiss it.
gates.withdraw.whenpredicateWhen an open gate the workflow opened is withdrawn, over its unit’s facts; it may read no untrusted fact, since a withdrawal closes a gate that waits on you. Required.
gates.withdraw.reasonstring, localizedWhy the gate was withdrawn, as its task’s timeline shows it.
gatestableThe gates its runs and drafts open.
gates.approvaltableThe approval of its gates: a rule applies to the kinds its when pins gate.kind to, else to draft gates alone when it reads a draft’s facts, else to every kind; what no rule or mode here decides, gates.toml’s [approval] does.
gates.approval.modeone of wait, delay, send, dry_run, runThe mode of every kind of gate when no rule holds; left out, gates.toml’s default of the kind.
gates.approval.windowdurationWith mode delay only: how long a person has to stop the gate’s consequence.
gates.approval.rule[]tableA rule; the first whose when holds gives the mode.
gates.approval.rule[].whenpredicateWhen the rule applies to a gate.
gates.approval.rule[].modeone of wait, delay, send, dry_run, runThe rule’s mode.
gates.approval.rule[].windowdurationWith mode delay only: how long a person has to stop the gate’s consequence.
KeyKindDefaultMeaning
draftstableThe drafts the workflow’s steps and turns propose.
drafts.withdrawtableWhen an open draft the workflow proposed is withdrawn.
drafts.withdraw.whenpredicateWhen an open draft the workflow proposed is withdrawn, over the unit’s facts and the draft’s.
drafts.withdraw.reasonone of assigned, moot, outside, joinedmootThe reason the withdrawn draft stores.
drafts.withdraw.rule[]tableA rule tried before when, in order, that withdraws with a reason of its own.
drafts.withdraw.rule[].whenpredicateWhen the rule withdraws an open draft the workflow proposed, over the unit’s facts and the draft’s. Required.
drafts.withdraw.rule[].reasonone of assigned, moot, outside, joinedmootThe reason the draft the rule withdraws stores.
outboxtableHow the Outbox heads the group of the drafts the workflow proposed.
outbox.titlestring, localizedThe group’s title, what the drafts are for, such as “Ask who hasn’t answered”; the summary when absent.
outbox.aboutstring, localizedOne line under the title on when the workflow drafts them, such as “Drafted when a conversation where you asked something goes quiet.”

Steps run in order on each matching unit. The vocabulary is closed; the facts page lists what each step does, and wloop workflows steps prints them with their arguments. A step takes only its own arguments:

StepArgumentKindDefaultMeaning
turn.dismissreasonstringThe reason the dismissed turn records.
answer.refusereasonstringThe reason the refused answer records.
turn.startintentstringThe intent whose turn starts; it must serve the unit. Required.
turn.startstageone of first, follow-upThe pass over the unit: its first, or one after.
turn.startrunnerone of session, headlessHow the turn runs, one of the intent’s runners; by default its first.
turn.startopen_questionone of hold, replace_if_movedholdWhat an open question of the intent’s newest turn does to a round of the unit’s plan: hold waits for its answer; replace_if_moved runs a round planned on a head other than the one the question was asked on, which replaces the asked turn, supersedes its gate and carries the question in the new turn’s history. An answer on its way holds the round either way.
draft.proposepurposestringWhat the draft is for, such as nudge or close. Required.
draft.proposedeliveryone of message, reaction, comment, edit, deletemessageHow the draft posts: the core’s message or reaction, or a delivery a plugin declares.
draft.proposetextfiletext.md.tmplThe template of the draft’s text, beside the manifest, rendered in the language of the draft’s conversation.
draft.proposewhyfilewhy.md.tmplThe template of why the draft is proposed, beside the manifest, rendered in the user’s language over the same data as the text; a workflow with no such file proposes with no why.
draft.proposereactionstringThe emoji name, with delivery reaction.
draft.proposeitemsone of owed, resolved, unowned, unowned_aged, allallWhich of the unit’s items the draft lists.
draft.proposeperone of unit, personunitOne draft per unit, or one per person it waits on.
draft.proposetargetone of unit, item, personunitWhere the draft posts: on the unit, on each item, or, with per = “person”, in the direct conversation between you and the person it is to; an intent’s help.target is free text.
draft.proposetoone of all, members, non_membersallWhom of the people the items wait on the draft addresses: all of them, those its unit’s plugin knows are members of the conversation it posts on, or those it knows are not; a person whose membership the plugin cannot tell is left out of both.
draft.proposeeffectslist of task.nudged, unit.closed, pr.nudgedWhat sending the draft applies.
draft.proposerepeat_afterstringThe least time after a settled draft of the same person before another: a duration, or a config.* duration fact such as config.drafts.repeat_after.
draft.proposerepeat_after_sentstringThe same after a sent draft, by default repeat_after: a duration, a config.* duration fact, or forever.
draft.proposeendedone of hold, repeatholdWhat a draft withdrawn as its task ended does while that task stays ended: hold the workflow’s proposals of no task or of that task, or hold them for repeat_after like any settled draft.
classifypromptfileprompt.mdThe classification’s prompt, beside the manifest.
classifyschemafileschema.jsonThe JSON schema of its answer, beside the manifest.
classifywithstringA list fact of task ids, such as task.candidates, whose tasks the prompt reads beside the unit: the prompt is then a template over .Facts, the unit’s facts, .Task, the unit’s own task when it is one, and .Tasks, the listed tasks in order, each with .ID, .Title, .Notes and .Items, a few of its items’ summaries. Every untrusted value is inside this call’s markers, .Begin and .End, which carry the code .Nonce. The output is cached by what the prompt reads of the unit’s task and the listed tasks (ids, titles, notes, item summaries) and by the prompt and the schema, so a write that changes none of them, such as opening a task, keeps it, and an edit of the prompt or the schema asks again; the unit’s facts a prompt prints through .Facts are not part of the key, and with a unit that is not a task the key holds the unit’s fingerprint too. Only a reader that names it reads it. An empty list runs nothing.
notify.selftextfiletext.md.tmplThe template of the notification’s text, beside the manifest: .Workflow, .Unit and .Facts.
task.verbverbstringThe verb of tasks.toml run on the task, one whose by.workflow lets a workflow run it. Required.
task.verbuntilstringWith a verb whose handler is schedule, the date the task returns: RFC 3339 or YYYY-MM-DD.
task.verbon_activitystringWith a verb whose handler is schedule, true returns the task on new activity on its unit.
task.verbonstringWith a verb whose handler is waiting, the people the task waits on, comma-separated.
task.verbresurfacestringWith a verb whose handler is waiting, the date the task resurfaces: RFC 3339 or YYYY-MM-DD.
task.verbsetstringWith a verb whose handler is context, the contexts, comma-separated; with area, the area.
task.verbbystringWith a verb whose handler is promise, the time the task is promised by: HH:MM, YYYY-MM-DD or RFC 3339; empty clears it.
task.verbtaskstringWith a verb whose handler is merge, split, relate or unrelate, the fact that names the other task: a string fact holding its id, or a list fact whose first element does, such as task.link_target_tasks, or a classify output, such as classify.<workflow>.task, which no fact table declares before it runs; the step is refused while it names none.
task.verbeachstringWith task naming a list fact, true runs the verb with every task the list names, in order, each opening its own gate where the verb is gated, so one that waits for you does not hold back the rest; else the verb runs with the first.
gate.opennamestringThe gate, of gates.toml, the step opens. Required.
items.resolveresolutionone of done, answered, fixed, delegated, declined, dismissed, superseded, mootdoneHow the items resolved.
task.setpropstringThe property, of tasks.toml, the step sets. Required.
task.setvaluestringThe value, written as its type reads it: an enum’s value, a date as YYYY-MM-DD or as a duration after now such as +2d, a duration, a list comma-separated. Required.
  • A guard refuses a step instead of skipping it: the item stays in the plan with the reason, and runs when the guard passes.
  • A step that fails retries with backoff, three times per key, then stays blocked with its error.
  • workspace.remove never forces: a manifest that sets force fails to load.
  • A turn.start naming an unknown, broken or builds intent breaks its workflow. On a pull request, caps, the brief, and the busy and held checks apply as for any automatic round. On any other unit, the workflow must set limits.per_day, and every such turn of every workflow counts toward turns.per_day for the local day; a turn of the intent still open on the unit, or an intent whose repository comes from an input, refuses the step. The turn runs with the step’s runner, else the intent’s first runner, where the intent’s workspace says.

[gates.approval] says how the gates the workflow opens are decided: the plan gate of each matched item, the drafts its steps and turns propose, and the gates its gate.open steps open. [[gates.approval.rule]] overrides its mode for the gates a rule’s when holds for, the first match winning; a rule applies to the kinds its when pins gate.kind to, to draft gates alone when it reads a draft’s facts, else to every kind, and one that reads gate.kind alone decides every gate of its kind. For an item:

  • mode = "run" (or "send") runs it at once, the default of gates.toml for a plan gate;
  • mode = "delay" with a window runs it when the window ends unless you act;
  • mode = "wait" keeps it pending until you apply or dismiss it, as the workspace workflows’ rule does for a workspace with unsaved work, with the reason shown;
  • mode = "dry_run" is a draft gate’s only: a rule that pins gate.kind to plan with it fails to load.

The bundled turn.start workflows hold their runs 60s with a rule pinned to gate.kind == "plan", mode = "delay" and window = "60s", which leaves their turns’ drafts to the intent. A rule that releases a gate (send, run or delay) never reads an untrusted fact, trigger.event.data included; a wait rule may, and the workflow’s when reads any fact. observe = true plans items and never runs them on its own: you apply one by hand. Dismissing or skipping an item is final for its key; holding, pausing or starting the round by hand drops the item without marking it.

For a draft, [gates.approval] tries its rules in order and gives it the mode of the first whose when holds, else mode:

[[gates.approval.rule]]
when = 'draft.delivery == "message" && draft.purpose == "thanks" && slack.conversation == "dm"'
mode = "send"

It covers the drafts of the turns the workflow starts and of its draft.propose steps; when it names no draft gate, a turn’s drafts follow the intent’s, and a step’s take gates.toml’s default, which waits for you. A rule’s when reads the draft approval facts and the unit’s facts, never brief.*, trigger.*, config.* or handled(). draft.purpose is the draft.propose step’s purpose; for a draft of a turn the workflow started it reads as empty, since the turn’s agent names it. A rule that sends on its own (send, run or delay) applies to a draft whose delivery posts, as its plugin’s manifest marks it, only when draft.delivery == "<delivery>" is a term of its when’s top-level &&, and mode alone never releases a comment or a message. dry_run stores the draft and never sends it. [drafts.withdraw] when withdraws an open draft the workflow’s draft.propose proposed, with its reason.

The language is the one of the intent manifest, with the workflow facts. !x == y and !x in l fail to compile with a hint: write !(x == y).

A subtraction from a zero timestamp is an evaluation error, so providers omit a fact instead of returning zero; test for it first (session.idle_since is absent for a session never seen idle, and idle_for is 0).

A mark is what a unit’s trigger already handled, kept on the unit after the plan and its runs are gone. [marks] names a string, time or int fact of the provider’s slot for each mark; an item takes the facts’ values when it is planned, and running it to the end or dismissing it stores them on the unit as <workflow>/<name>. A predicate reads a mark back with handled("<workflow>/<name>", <fact>):

summary = "Address a lasting conflict once per head"
provider = "github"
when = 'pr.mine && pr.conflicting_for > duration("10m") && !handled("conflict-once/head", pr.head)'
key = ["pr.head"]
[[step]]
use = "turn.start"
intent = "address"
[marks]
head = "pr.head"

handled compares by the fact’s type: a string is a version, handled by the same value; a time or an int is a watermark, handled up to the stored one, which a later run never moves back. A unit with no such mark has handled nothing, and an empty fact (an empty string, a zero time) counts as handled, since there is nothing to handle. A workflow writes only its own marks. It may read any mark a loaded workflow records, and the marks the plugins declare, listed on the facts page. The mark’s name is a string literal, checked when the profile loads. A mark name has no slash, space or control character.

Terminal window
wloop profile check
wloop plan --workflow <name>
wloop profile schema --workflow > <profile>/workflows/workflow.schema.json

With #:schema ../workflow.schema.json at the top of a manifest, Taplo-based editors complete and check it as you type. A workflow that fails to load disables only itself; profile check and doctor list its problems.