This page is the detail behind the Triggers section of the README. A trigger is
one entry in triggers.json. It says what starts a job and what that job runs.
pi-dispatch is the trigger layer. Every entry is one { on, run } pair: on is what fires it, and
run is what it runs, either a flow or a registered command (flow names a .pi/skills/<flow> in
the target repo: see Flows for what that file is, and
Multi-stage workflows for chaining skills or
staging a workflow extension).
on.type |
Fires on | Required in on |
What narrows it | What the agent gets as its task |
|---|---|---|---|---|
cron |
your schedule | id (unique, no :) · pattern (5 or 6 cron fields) |
nothing: a schedule is its own condition | run.task, written in the file |
label |
a label on an issue (or an Azure work item), never a pull request | at least one positive selector, any or all |
the label predicate: any (any of these) · all (all of them) · none (suppress-only, it can prevent a fire but never cause one) |
the issue title and body |
comment |
a comment containing your phrase | phrase, for example @pi |
the phrase, and one comment trigger per forge | the comment body plus the issue title and body |
pull_request |
a PR or MR event, including a submitted GitHub review, and including its close (the close word rides alone, never mixed with other actions; on GitHub and Forgejo a merged PR counts as closed, on GitLab only an explicit close fires it) | action, a non-empty array in your forge's own words |
action, plus the same label predicate; where the forge has a label action and you name it, a positive selector becomes required; on a GitHub review, also reviewState; on a close-only rule, number and once instead of the predicate |
the PR title and body, plus the review body when a review fired it |
issue |
an issue closing, and only that (labels have label, phrases have comment) |
action, the close word in your forge's own words |
number pins it to one issue (on GitLab, the iid); once: true makes it a one-shot and requires number |
the issue title and body |
One variation changes that last column for every type. A trigger that names run.command instead of
run.flow gives the agent exactly /command args as its whole prompt. The issue, comment or PR text
waits in /job/event.json for the command's handler to read (see
Multi-stage workflows).
Every type also needs run.kind (local for cron, else the forge). You must pick exactly one of
run.flow or run.command (naming both, or neither, refuses to load in both services). Cron additionally
needs folder (a host path the worker checks exists when it loads the file; make it absolute, since a
relative path resolves against the worker's own directory) and, with flow, a task. Azure label and
comment triggers need run.repository, because a work item belongs to a project and names no repository.
The webhook types also charset check run.flow at load (skill names are lowercase). A flow that could
never name a repo skill is refused when the file loads, at no cost, instead of failing inside a paid
container.
Two matching behaviours to know before you turn on a paid trigger:
- A comment can choose the flow.
<phrase> <flow>in the comment body overrides the trigger'srun.flowwhenever that word matches another trigger's flow in the same file. Sorun.flowis a default rather than a fixed pairing. On a rule that namesrun.commandthis channel is inert. Trailing words never retarget or suppress the command, and reach the job only as data in/job/event.json. - Label triggers match differently per forge. GitHub and Forgejo match the issue's whole current label set. Reopening an already-labelled issue, or adding an unrelated label to one, fires again. GitLab and Azure match only the labels that event added. This is exactly why they do not re-fire that way.
action words are each forge's own vocabulary. They are validated at load so a word from the wrong forge is
refused rather than silently never matching:
run.kind |
pull_request actions |
Its label action | Notes |
|---|---|---|---|
github |
labeled opened synchronize reopened review_submitted closed |
labeled |
review_submitted is the pull_request_review event's submitted action. A formal Approve or Request changes starts a job. It is gated on the reviewer's permission, never the PR author's. A collaborator reviewing a stranger's fork PR runs and a stranger reviewing their own PR does not |
gitlab |
open update reopen approved close |
none | a label add arrives as update carrying a label diff. A predicate here matches the labels that update added. approved is one verdict where GitHub's review_submitted is every verdict |
forgejo |
label_updated opened synchronized reopened closed |
label_updated |
label_cleared fires nothing, ever. Removing a label must never start a paid run |
azure |
created updated |
none | a label predicate on an Azure PR is refused at load. Azure tags work items, never pull requests. A close trigger (issue, or a close-only pull_request rule) is refused at load too. It is not yet covered, because a work item's close is a state transition the payload subset cannot see |
A review trigger is wider than it looks, so narrow it. review_submitted fires on every submitted
review: an Approve, a Request changes, and a one-word "lgtm thanks" alike. Unlike a comment trigger there
is no phrase in the way and unlike a label trigger there is no label. Arming it means anyone with write
access starts a paid run by reviewing. Add reviewState (GitHub only, and only beside review_submitted)
to pick the verdicts worth paying for:
{ "on": { "type": "pull_request", "action": ["review_submitted"], "reviewState": ["changes_requested"] },
"run": { "kind": "github", "flow": "address-review" } }Two behaviours to know before you turn it on. A Comment type review whose remarks are all line comments
and whose summary box is empty starts nothing. Those remarks arrive on an event this service
does not read, so the review reaches us empty. Approve and Request changes still fire with an empty summary,
since there the verdict is the signal. The bot loop guard knows only our own identity. Another
bot that reviews (a CI bot, a review service) can start jobs if it holds write access.
SECURITY.md
states both, and the flow gets review.id in /job/event.json so it can fetch the line comments itself.
Who may fire a trigger is not ours to grant. Your forge decides that, differently per forge.
SECURITY.md states each one plainly (short version: on GitHub only collaborators can
apply a label, which is why the label is the approval there; GitLab, Forgejo and Azure resolve the
actor's permission through their APIs because a label proves less on those). The always-on gates that
every delivery passes are the signature check, the bot-loop guard, that permission check, dedup, quiet
hours, the image preflight, branch protection, and the spend caps. None of them are per-trigger.
"When this closes, run that, once." An issue trigger fires when an issue closes. A PR close uses
pull_request with the close word as the only action in the rule. A close is gated on a different
actor than every other PR action, so the words never mix. On GitHub and Forgejo a merged PR emits
closed and fires the rule. GitLab reports a merge as its own action, which no rule takes, so only an
explicit close fires there. number narrows the rule to one item. once: true spends it after a
single run. once requires number, and never sits beside run.replicas:
{ "on": { "type": "issue", "action": ["closed"], "number": 40, "once": true },
"run": { "kind": "github", "flow": "deploy" } }The gate is the closer's. The actor who closed the item must hold write access. On GitHub that is resolved through the collaborator permission API, because the payload names only the author, and an issue's own author can close it with no access at all. On GitLab and Forgejo the existing member lookup already checks the sender, who is the closer.
Spending is written into the file itself. After the run record exists, the worker adds
"disarmed": { "at": ..., "jobId": ... } to the entry's on. The entry is never deleted, because run
history refers to triggers by their position in the file. A spent entry still shows in the panel with a
spent marker while
matching nothing. Deleting on.disarmed re-arms it; nothing else is needed. A one-shot whose
run failed still counts as fired. The record is the definition, so a failed run spends it too. The
fix is that same one key deletion.
Four deployment notes:
- Point both services at the same file (
PI_TRIGGERS_FILE, absolute;doctorwarns). - In the shipped compose topology the receiver's read-only single file mount keeps serving the old bytes after a disarm until the container restarts. The worker's own check before any spend is what prevents a second run meanwhile.
- The polling transport carries closes too. The events feed includes them. A close trigger needs no public URL.
- A symlinked
triggers.jsonis replaced by a real file on the first disarm. Keep the real file at the served path and symlink the other direction.
Each one is a deliberate edit to the file: neither the panel nor any AI tool can set it, because each one changes what code runs or what it costs.
-
"command"replacesflow. You can use exactly one of the two on any trigger type. The job runs a registered pi extension command headlessly, with no interactive session. The whole prompt is/command args. The arguments are fixed in the reviewed file. The event text reaches the handler only as/job/event.json, which it reads itself. A command is never AI-triggerable. Job chaining refuses any request that names one.dispatch_runcannot express one (docs/workflows.md). The job image must declare thecommandscapability. The shipped image does. A command job on an image that does not declare it is refused before it costs anything. -
"image"names the container image for that trigger's jobs. Without it, jobs usePI_JOB_IMAGE. The image decides what is in the box, never what the box can do: the worker sets the isolation flags itself (docs/job-image.md). -
"packages": falseopts one trigger out of the staged third-party pi packages. This is also how a workflow extension is withheld from one flow (docs/workflows.md). -
"excludeTools": ["bash", "powershell", "edit", "write"]removes named built-in pi tools from that trigger's sessions. The session itself enforces this, not the prompt text. The excluded tools are gone from the tool registry. Nothing running inside the job can switch them back on. This only narrows tools. Only the pinned pi's built-ins can be excluded:read,bash,powershell,edit,write,grep,find,ls. A misspelled name is refused when the file loads. Pi would otherwise ignore it silently. The job image must declare theexcludeToolscapability. The shipped image does. A job carrying exclusions on an older image is refused before it costs anything. That image's runner would run the job with every tool you removed (docs/exclude-tools.md). -
"mcp": ["docs", "files"]names MCP servers from the overlay'smcp.jsonfor that trigger's jobs to connect (docs/mcp.md). Names only: the servers themselves, and the credentials they take, are the operator's, in the overlay. A job that names a server it cannot use is refused before it costs anything: a name the overlay does not offer, a credential the job will not have (passed neither insecretsnor byPI_FORWARD_ENV), or, with the egress policy on, a server the proxy would not let the job reach. The job image must declare themcpcapability. A chained child does not inheritrun.mcp, and the admin's tools cannot set it. Likemodels, addingrun.mcpto a trigger refuses its jobs that were already queued (trigger-skew): re-run them. A key underrunoronthat is not plain ASCII and holds no plain letter outsidemcpserversis refused, since one in another script can be drawn exactly likemcp. On a github or gitlab trigger the list may also nameforge: the job's own GitHub or GitLab through that forge's MCP server, as the token the job already holds, with no overlay entry (docs/mcp.md). A cron, forgejo or azure trigger namingforgeis refused at load, and an image without theforgeMcpcapability refuses its jobs before they cost anything. -
"skillsDir"points at a directory of skills on the worker host. The layout is the same as your own~/.pi/agent/skills, which uses<name>/SKILL.md. They are copied into that trigger's jobs. They are layered under the repo's own.pi/skillsand over the global overlay. A repo skill of the same name still wins. Use it to run a flow against a repo that has not adopted.pi/skills/at all. Use it to A/B two versions of a flow across two triggers. Use it to keep a private flow out of a public repo's history (docs/global-pi-overlay.md). -
"replicas": 2races independent sandboxes on one event and opens one review request per replica, on any forge. Webhook triggers only: the poller does not fan out. Each replica spends its own budget slot (docs/replicas.md). -
"instructions"attaches one line of standing text to that trigger (forge triggers only, up to 2000 characters). It reaches the job's prompt above the issue or PR text, labelled as coming from you and not from the issue. So "the tests run with pnpm here" applies to every run of that trigger without being committed to the repo or added to the deployment wide persona. Cron triggers usetaskinstead, which is the same text in the same place. -
"secrets": { "STRIPE_KEY": "op://ci-vault/stripe/api-key" }names vault references this trigger's jobs receive as environment variables. Use"secretsProfile"to choose which of your declared resolvers reads them. The worker runs your one line script (exec op read --no-newline "$1") on the host before the container starts. The job gets values. It never gets your manager's credential (docs/secrets.md) -
"secretBundle": falsekeeps this trigger's jobs out of their project's secret bundle, the one environmentsecretBundlesat the top of this file grants a whole project (docs/secrets.md). The worker reads it from its own copy of this file, so no receiver has to carry it -
"waitFor": [{ "after": "2026-09-01T09:00:00Z" }, { "profile": "jira" }]holds this trigger's job in the queue, unstarted and unbilled, until every condition clears. An instant costs nothing. A profile names one of your own check scripts. The worker runs it on the host and reads the exit code (docs/wait-for.md). -
"resume": truecontinues the session that opened the PR (docs/sessions.md). -
"github": trueon a cron trigger gets the same per-job GitHub token the webhook path gets. A scheduled flow can usegh. -
"portfolio": trueon a cron trigger makes its jobs portfolio jobs: the one kind of job that may send a budget priorities plan back (docs/allocation.md). Cron only. The file refuses it on a webhook trigger, and beside"command", because a plan should come from a flow you reviewed. It must betrueorfalse. When such a job starts, the worker reads the triggers file again. If the flag is gone, or the entry with that id now has another folder, flow, command or task, the job runs as an ordinary cron job. If the flag is there and this worker's envelope does not letportfolio-jobwrite a plan (no envelope,delegation.enabledfalse, orportfolio-jobnot indelegation.writers), the job is refused asportfolio-no-envelopebefore it costs anything. A chained child never inherits the flag. A working flow to copy is indocs/portfolio-manager.md.
Two more fields change what you see of a run, not what it can do, so the AI tools that add and edit a trigger
(dispatch_trigger_add, dispatch_trigger_edit) can set them too, behind the operator's confirm:
-
"progress": truegives that trigger's jobs one more tool,report_progress, with which the agent names the phase of its plan it is in (up to 64 characters), optionally how far through it is (doneoftotal) and a short note (up to 200 characters). The console's run page and the/dispatchpanel show the phases; the turns, steps and the tool running show for every job without this field. The tool only records: it changes nothing and the run goes on. Any trigger type, cron included. The phase names and notes are the agent's words, so they are redacted and shown to operators only, never on the forge and never in the run record. The job image must declare thereportProgresscapability. The shipped image does. A job on an older image is refused before it costs anything. A job where another extension registers a tool of the same name stops before its prompt (report-progress-shadowed). -
"statusComment": truemakes the worker post one comment on the issue or pull request when the job's container starts, and edit it as the run goes, about every 30 seconds at most, ending with the outcome the run record gives (finished: completed,finished: policyand so on), orattempt ended, retryingwhen an infrastructure failure will be retried (the next attempt then ends it, also when that attempt is refused before it starts; a retry you cancel withpi-dispatch cancelwhile it waits is the exception, and its comment keeps saying retrying):pi-dispatch job `<id>`: running. Turn 7, 12 steps (1 failed). Phase 2 of 4 (3/10).It holds numbers only, never the agent's words, and it does not replace the agent's own comment or the worker's other comments. A retry edits the same comment. Webhook triggers only: a cron trigger has nothing to comment on and is refused. It needsPI_RUN_PROGRESSon (the default); with that at0the job runs without the comment, and the worker log says so (status_comment_skipped, reasonprogress-off). When the comment's text could contain the trigger phrase that started the job, the worker leaves the comment out rather than risk starting a job of its own (reasonphrase-collision). The same log line names the two rarer skips:no-transcript(the job had no transcript to read progress from) andno-forge-target(no forge the worker can post to and edit on). Anyone who can read the issue can see how long the run took and how many steps it made, so it is off by default.
Both must be true or false. Like models, adding either to a trigger refuses its jobs that were already queued
(trigger-skew): re-run them.
"recover": true opts a trigger into durable runs: when the worker loses one of its runs part way (a crash, a
kill, a host that went away, or a container that died on an infrastructure fault), the run continues from its last
checkpoint instead of failing or starting over, at most twice. A graceful stop of the worker is not a loss: it stops
the run as before. It needs a checkpoint store (PI_SESSIONS_DIR, whose .checkpoints folder holds them, or
PI_CHECKPOINTS_DIR), or its jobs are refused before they cost anything (recover-store-unset), and a job image
that declares the recover capability; a job on an image without it is refused the same way
(job-image-recover-unsupported). On a fleet, name every worker (PI_WORKER_NAME) so a recovery reaches the
machine holding the checkpoint. A repository whose default branch has .pi/extensions/ is not recovered: its runs
run as before.
Webhook triggers only: a cron trigger is refused, because its job edits your folder in place and a continuation
would replay over changes the lost run already made. It is also refused beside "command", because continuing a
command job would dispatch the command a second time. It must be true or false, the AI tools that add and edit
a trigger can set it behind the operator's confirm, and like models, adding it refuses the trigger's jobs that
were already queued (trigger-skew). How a run is checkpointed and continued is in
docs/durable-runs.md.
"steer": true opts a trigger into steering: while one of its jobs runs, operators (from the command line) and the
people your comment trigger accepts (with its phrase, on the job's issue or pull request) can send it messages, which
the agent reads between its steps. On a comment trigger it also decides whether a phrase comment may steer a
running job of the target instead of starting a new one, so set it on the comment trigger too if comments should
steer; <phrase> later ... sends a follow-up. A comment that arrives after the run ended starts a resumed job when
the comment trigger has "resume": true, or gets one fixed reply. It is off by default. It needs a job image that declares the steer capability, or its jobs are refused before they cost
anything (job-image-steer-unsupported), and a venue that bind-mounts. Every trigger type may set it, cron
included; it is refused beside "command". It must be true or false, the AI tools that add and edit a trigger
can set it behind the operator's confirm, and like models, adding it refuses the trigger's jobs that were already
queued (trigger-skew). How messages reach a running job is in docs/steering.md.
pi-dispatch run --trigger <id> runs one cron trigger now, once, exactly as its schedule would. Its folder,
flow, task and every other field come from the triggers file, so the command takes no other flag. This is how
you test a portfolio trigger without waiting for its schedule. pi-dispatch run <folder> makes a manual job,
which never carries the flag.
- It reads
PI_TRIGGERS_FILE,PI_WORKER_NAMEandPI_MAX_COST_USDfrom your shell, or else from the.envin the folder you run it from, as it readsVALKEY_URL. With noPI_TRIGGERS_FILEanywhere it reads./triggers.jsonthere. That is the file the worker checks theportfolioflag in, so run it from the deployment folder. It refuses when your shell and the.envdisagree on one of them, or when the.envcannot be read and your shell does not set it.PI_MAX_COST_USDis read so that a trigger whosemaxCostUsdthe worker refuses is refused here too.pi-dispatch run <folder>readsPI_WORKER_NAMEthe same way. - It refuses an id that is not a cron trigger in that file, and it refuses a file the worker would refuse.
- The trigger's folder must be a git repository with a commit, as for
pi-dispatch run <folder>. Uncommitted changes are fine, as they are for a scheduled run. - The job id is
manual:<id>:<minute>. A second call in the same minute queues nothing and says so. A later minute queues a new run. - The job's
/job/event.jsonsays"source": "cron"with the trigger's id and pattern, andscheduledForandpreviousRunAtarenull. It counts as a run of the trigger: the next scheduled run'spreviousRunAtcan name it. - On a fleet it queues on this host's own queue, because the trigger's folder is here. If the folder is on another machine, it refuses and tells you to run it there.
A local job's folder is resolved when the job is prepared, and the container mounts the resolved folder, not the
spelling you wrote. A folder named inside a job path (a cron trigger's run.folder or a PI_DISPATCH_RUN_ROOTS root)
must still resolve inside one. If it does not, the job is refused as local-folder-escaped, and nothing is spent.
The reason is that a job can write inside its own folder. A link planted there could otherwise send a later job, a
chained child or a dispatch_run job, to a folder nobody checked, mounted read-write.
So a run root used as a symlink farm (root/shop linking to /srv/shop) is refused. List the real folder instead:
add /srv/shop to PI_DISPATCH_RUN_ROOTS, or name it as the cron trigger's run.folder. A folder named outside every
job path, as in your own pi-dispatch run, is not held to them. See allocation for the two related
refusals, local-folder-holds-envelope and local-folder-project-changed.
Any trigger type can name the model its jobs run on, and how many turns a job may take:
{ "on": { "type": "label", "any": ["pi:triage"] },
"run": { "kind": "github", "flow": "triage", "provider": "openai", "model": "gpt-5.4-mini", "maxTurns": 10 } }-
"provider"is a pi provider id, such asanthropicoropenai. Since pi 1.0.3 the Azure OpenAI provider isazure, no longerazure-openai-responses. Rename it everywhere it appears: here, in"models"lists andPI_ALLOWED_MODELS(azure/gpt-5.4), inPI_PROVIDER, inmodel:rows of your scoped limits, inproviderinsubscriptions.json, and in the overlaymodels.json. A job on the old id is refused before it spends, and the refusal namesazure. An allowed-models entry on the old id matches nothing, and amodel:row on the old id caps nothing. If the worker takes the Azure key from the host's pi login, rename theazure-openai-responseskey in thatauth.jsontoazure, or log in again; otherwise the job is refused asprovider-unconfigured. A thread job that resumes an older session is safe: a session written by another pi version is never resumed, the job starts fresh. -
A pi bump can also remove models from pi's catalog. A trigger, an allowed-models entry or
PI_MODELthat names a model the new pi does not have is refused before it spends (model-unknown), anddoctornames it. Amodel:row in your scoped limits that names such a model caps nothing. pi 1.0.3 removed these ids (besides the Azure rename):cloudflare-ai-gateway:claude-fable-5.1,claude-haiku-4.5,claude-opus-4.5,claude-opus-4.6,claude-opus-4.7,claude-opus-4.8,claude-opus-5.5,claude-sonnet-4.5andclaude-sonnet-4.6. Each is now spelled with dashes (claude-opus-4-8).openrouter:openai/gpt-6.1-sol:batchandopenai/gpt-6.1-sol-pro:batch.together:deepseek-ai/DeepSeek-V4-Pro,google/gemma-4-31B-itandopenai/gpt-oss-20b.vercel-ai-gateway:inclusionai/ling-3.0-flash-sante-free.
opencode-go/qwen3.7-plusandopencode-go/qwen3.8-maxkeep their ids and moved from openai-completions to anthropic-messages. 57 models changed price; a run is priced at the rates of the pi it ran on.pi 1.0.4 removed two more ids, both on
openrouter:qwen/qwen3.8-27b:freeandstealth/space-bunny-alpha. 14 models changed price (12 onopenrouter, 2 onvercel-ai-gateway).pi 1.1.0 removed these ids:
opencode-go:space-bunny-free(space-bunnyis new).openrouter:kwaipilot/kat-coder-pro-v2.5, and the classifierperplexity/pplx-decider-v1-27b(perplexity/pplx-decider-v1.1-27bis new).vercel-ai-gateway:deepseek/deepseek-v3.1-terminus,moonshotai/kimi-k2-thinkingandstepfun/step-3.5-flash.
180 models changed price, most of them on
openrouter(101) andvercel-ai-gateway(48).pi 1.2.0 removed these ids:
google-vertex:gemini-flash-latestandgemini-flash-lite-latest.opencode:fledge-alpha-free.openrouter:inclusionai/ling-3.0-flash-sante:free(inclusionai/ling-3.0-flash-santeis new),qwen/qwen-plus-2025-07-28,qwen/qwen3-235b-a22b,qwen/qwen3-30b-a3b-thinking-2507,qwen/qwen3-8b,qwen/qwen3-coder-plus,qwen/qwen3-max,qwen/qwen3-max-thinking,qwen/qwen3-vl-235b-a22b-thinking,qwen/qwen3-vl-32b-instruct,qwen/qwen3-vl-8b-thinking,qwen/qwen3.6-max-previewandsao10k/l3.1-euryale-70b.vercel-ai-gateway:alibaba/qwen-3.6-max-preview,alibaba/qwen3-235b-a22b-thinking,alibaba/qwen3-coder-plus,alibaba/qwen3-max,alibaba/qwen3-max-preview,alibaba/qwen3-vl-235b-a22b-instruct,alibaba/qwen3-vl-thinking,anthropic/claude-3-haikuandanthropic/claude-opus-4.
54 models changed price, most of them on
openrouter(42); onanthropic,claude-sonnet-5-5's cache read halved.anthropic/claude-sonnet-4-5(and its dated id) now has a context window of 200k tokens, down from 1M. -
"model"is that provider's model id. Case is kept as you wrote it. -
"maxTurns"is a whole number of 1 or more.
Each one is optional. A field you leave out is not written into the job. The job then takes the value
from the settings overlay, else from the environment (PI_PROVIDER, PI_MODEL, PI_MAX_TURNS), when it
starts. A trigger that names only a model runs on the deployment's provider, so name both when they
belong together.
The file is checked when it loads, in the worker and in the receiver:
- A provider is 1 to 64 characters: letters, digits,
.,_and-, starting with a letter or digit. - A model is 1 to 64 characters: letters, digits and
._-:/@, starting with a letter or digit, or with~or@and then one. This is the same rule the run history uses for the model rows it keeps, so any model a trigger can name is one whose usage can be recorded. - A number, an empty string, a space, a 65th character or a non-ASCII character is refused. The message names the trigger and the field. It does not repeat the value.
- A near miss of a field name is refused too:
providerId,providers,modelId,modelName,model_id,maxTurn,max_turns,allowedModelsand other case or separator variants. A misspelled key would otherwise be dropped, and the job would run on the default model while the file reads as though it chose one. The same keys underonare refused in every spelling.
The load check does not ask whether the model exists, because the answer depends on each worker's
pi version and overlay. The worker asks instead, when a job starts and before it spends anything: a
model that is in neither pi's model catalog nor the overlay models.json is refused as model-unknown.
No token is minted, nothing is cloned and no budget slot is taken. The model a job runs on must be a
chat model; an image or classifier model can only be a list entry (below). The overlay models.json is
read the way pi reads it: // comments, a byte order mark and trailing commas are fine. One wrong-typed
field anywhere, a /* */ comment, a truncated write, a misspelled providers, a file saved as UTF-16, or an
empty file makes pi drop the whole file. pi then also loses the file's entries for builtin providers, such
as a baseUrl for openai, and would run their models against the provider's public endpoint. So while
the overlay models.json is broken, the worker refuses every job (model-unknown, with
overlay-unparseable in the worker log), whichever model it runs or lists. The same holds when
models.json is a directory (overlay-is-a-directory), and when the worker cannot read it because of the
permissions on the file or its folder (overlay-unreadable): pi in the job then loads none of the file,
because the runner's existence check, or pi's own read, fails. It also holds when models.json is a link
(overlay-link), since the job's read-only mount does not follow links the way your host does: replace the
link with the file itself. The same holds when models.json is a named pipe, a socket or a device
(overlay-not-a-file): the worker never opens it. pi-dispatch doctor says so. Fix the file, make it
readable by the account the worker runs as, or remove it: a missing models.json is no overlay, and jobs run.
A read that fails for a moment (a disk error, too many open files) is not a refusal: the job is retried once,
then failed. pi also drops a provider it cannot put
together: a model with no api or baseUrl to be found, a contextWindow or maxTokens of zero or less, or
oauth without a baseUrl. pi then ignores that provider's whole entry, its baseUrl and headers
included. So every job that runs or lists a model of that provider is refused (overlay-provider-invalid),
also a job that only lists one and never calls it: an overlay model of it does not exist, and a builtin one
would quietly run against the provider's public endpoint instead of yours. pi-dispatch doctor names each
such entry.
Upgrading: the model check. It covers the deployment default too (PI_MODEL, or the settings overlay), not
only a trigger's own model. A main model that only an extension defines inside the job
(pi.registerProvider) was already refused before this release, inside the container and after the job
had taken its budget slot. It is now refused before anything is spent. Declare such a model in the overlay
models.json. A virtual model (pi.registerVirtualModel) cannot be the main model: set PI_MODEL or
run.model to a physical one.
dispatch_trigger_add and dispatch_trigger_edit can set provider and model, behind the same
operator confirm as every other trigger write. Both check the value with the same rule before they ask.
The panel's drill-in and dispatch_triggers show a trigger's own model, provider and turn limit on every
trigger type.
Upgrading. Before this release a webhook trigger's provider, model and maxTurns were ignored, and
its jobs ran on the deployment default. If your file already sets them on a label, comment, pull request
or issue trigger, they now take effect. A value that loaded before but breaks the rule above (a number, a
space, more than 64 characters) now refuses the whole file, in the worker and in the receiver alike, as
does a misspelled key such as modelId. A null value is still accepted on every trigger type and
means the deployment default, as it did before. Run pi-dispatch doctor after upgrading to see any such
line.
"models" lists the models a trigger's jobs may call, on any trigger type:
{ "on": { "type": "label", "any": ["pi:triage"] },
"run": { "kind": "github", "flow": "triage", "provider": "openai", "model": "gpt-5.4-mini",
"models": ["openai/gpt-5.4-mini", "anthropic/claude-haiku-5-5"] } }- Each entry is
provider/model. It is split at the first/, so a model id may carry more slashes (openrouter/~anthropic/claude-sonnet-latest). Each half follows the rules above. - A list has 1 to 16 entries. An empty list is refused: leave the field out for no limit.
- An entry that repeats another, ignoring case, is refused.
- When the trigger names
provider,modelandmodelstogether, the main model must be on the list, spelled exactly as in the list. Otherwise the file is refused, because every job would be.
A deployment can set a default list with PI_ALLOWED_MODELS in .env, in the same form, comma
separated, with no spaces (a shell that sources .env cuts the value at a space and leaves it unset, so
the worker refuses a spaced value at boot, and pi-dispatch doctor names it). A trigger's own models replaces it for that trigger's jobs. With neither, a job may call any
model, as before. The default list is read from the environment only. The settings overlay cannot set
it, so no AI tool can widen it.
Before a job spends anything, the worker checks every model on its list exists (model-unknown), and
that the model the job runs on is on the list (model-not-allowed). The second catches a default model
that is not on PI_ALLOWED_MODELS, and a dispatch_set model that moves the default off a trigger's list.
pi-dispatch doctor asks the same questions of the triggers file before any job does:
- It names a trigger, of any kind, whose own model or a model on its list would be refused, with the reason,
and the deployment's own default model or a
PI_ALLOWED_MODELSentry that would be (a typo there refuses every job that names no model of its own). - Only the job's main provider gets its key automatically. Doctor warns when another provider on a list has
no key the job can receive: none of pi's variables for it is bound by the trigger's
run.secrets, or named inPI_FORWARD_ENVand set. Such a job starts, then fails its first call to that provider. - It asks pi's own loader how it reads the overlay
models.jsonand warns where pi and the worker disagree about the file or about one of its models. It runs only where the pinned pi is installed beside the worker, which is a checkout of this repository afternpm ci(the same pi the job image is built with). Otherwise it says the comparison was not made: no pi, or a pi of another version, such as a globalpiinstall.
Inside the container the runner checks every call before it is sent:
- The first call to a model that is not on the list stops the whole job (
model-not-allowed). The call is not sent. - Matching is exact and case-sensitive, on both the provider and the model id.
- A classifier or image model a flow uses must be on the list like a chat model.
- A virtual model (a router an extension registers) is judged by the model it routes each request to.
- A model can declare server-side fallbacks (
compat.allowedFallbackModels, in pi's builtin catalog or set inmodels.json). Each must be on the list too, under the model's own provider. In the builtin catalog onlyanthropic/claude-fable-5has any: a list naming it must also nameanthropic/claude-opus-4-8andanthropic/claude-opus-5. The worker refuses a list that misses one before the job spends anything (model-not-allowed), on any api. Only theanthropic-messagesapi sends fallbacks with a call, and there the provider may answer with any of them, so the runner refuses such a call too. A listed fallback that answers is logged asmodel_fallback. - A hook that changes the request (an extension's
before_provider_request, or anonPayloadoption) is denied by default. It may change only these TOP-LEVEL fields of the request: the messages (messages,input,contents), the system prompt (system,systemInstruction,instructions) and the sampling settingstemperature,top_p,top_k,min_p,stop,seed,presence_penalty,frequency_penalty,max_tokens,max_output_tokensandmax_completion_tokens; for Google, also the sampling fields and system instruction insideconfig. Any other change refuses the call before it is sent: a new model, a fallback list, Google'sconfig.httpOptions, a gateway'sproviderOptions,prompt,metadata, or any field added, removed or changed. That includes sampling settings an api keeps somewhere else, such as Bedrock'sinferenceConfig, Mistral'smaxTokensor pi-messages'contextandoptions: they are refused, not translated. A field set to nothing (undefined) counts as absent, so a hook that copies the request through JSON passes. - A call that would name another model some other way is refused too:
model,modelId,models,fallbacksorproviderOptionsinsamplingParamsor in any level ofsamplingParamsByThinkingLevel(pi picks the level per call, so every level counts), an Azure deployment chosen per call (on any model of theazureprovider), or a caller's ownfetch. OthersamplingParamskeys, such asmin_p,reasoning_effort,chat_template_kwargsorservice_tier, change how a model answers, not which one, and pass. A hook that edits those same settings is refused, because hooks are denied by default:samplingParamsis checked against a short list of keys that route, a hook against a short list of keys it may change.
Two things about a hook that changes the model, which an operator reading the usage ledger should know:
- With no list, nothing judges the hook. A
before_provider_requesthook that rewrites the request's model sends the call to that other model, but the ledger records it under the model the job asked for. A list refuses the same rewrite before it is sent. - A rewrite the list refuses still counts as 1 call with 0 tokens on the requested model's usage row, though nothing was sent. The call reached the provider function, which then refused it.
The list reaches the container only on a job image that declares the modelPolicy capability. On an
older image a job with a list is refused before it spends (job-image-model-policy-unsupported), never
run without its limit.
A chained job (/outbox) keeps its parent's provider, model and list. A request file cannot change them.
If a listed model is served by a declared model endpoint (docs/egress.md), the job holds
a slot on that endpoint too, not only on its main model's.
No AI tool can set models. Edit the file. dispatch_trigger_add and dispatch_trigger_edit refuse a
call that carries models, and an edit keeps the list the entry has. dispatch_trigger_edit also refuses,
before it asks, a new provider or model that is not on the trigger's own list. dispatch_triggers and the
panel show the list.
Upgrading, and services out of step. A receiver from before this release does not know models and
drops it, and so does a current one that still reads an old copy of the triggers file (compose's
single-file :ro mount keeps the old file until the receiver restarts). The job would then run on the
deployment's list, or on none. The worker reads the triggers file itself and refuses such a job before it
spends (trigger-skew, naming the field), the way it refuses a job that lost its waitFor. It can only do
that when it can read the file: PI_TRIGGERS_FILE, else triggers.json in the worker's folder. A worker
that cannot read it cannot see the gap. The check is strict: adding models to a trigger refuses the jobs of
it that were already queued (trigger-skew), and the comment says so. Re-run them. The worker finds a job's
trigger by its position in the file. Inserting a trigger with the same flow, kind and type ahead of others
moves the ones after it down. A queued job of a moved trigger can then be checked against its neighbour, and
refused as trigger-skew when that neighbour sets models, maxCostUsd, mcp, awaitInput, progress,
statusComment, recover or steer. The comment says the trigger
changed after the job was queued. Re-run those jobs too. Upgrade the worker and the receiver together, and restart the
receiver after editing the file.
Everything else is editable from the panel (a adds kind-first, e edits the flow, x deletes) or via
the confirm-gated AI tools. Every write is validated. Both services reload it live. The worker itself
writes exactly one thing back, the on.disarmed mark. That mark spends a one-shot. Every local job also
receives a read-only /job/event.json. It contains source, folder, and HEAD sha. Cron adds its id,
pattern and schedule instants. A scheduled flow can triage only what changed since its last run.
Any trigger type can set the most one of its jobs may spend, in US dollars:
{ "on": { "type": "label", "any": ["pi:triage"] },
"run": { "kind": "github", "flow": "triage", "maxCostUsd": "0.50" } }"maxCostUsd"is a plain decimal: above 0, at most 1000000, with at most 6 decimals. Write it as a string, such as"0.50". A string is checked exactly as you typed it, so"0","-1","1e3"and"0.1234567"are refused when the file loads, in the worker and in the receiver. The message names the trigger and the field, never the value.- A JSON number also works (
0.5,2), but it is read by its value, not by what you typed. JSON turns1e3into 1000 before anything sees it, so1e3unquoted loads as $1000, and digits past what a float can hold are lost. Quote the amount to have it checked as written. - The runner checks it before every model call. A call that could take the job past the cap is not sent,
and the run stops with reason
cost-cap. - It can only lower the cap. The job runs under the smaller of this value and the deployment's
PI_MAX_COST_USD(or the panel'smaxCostUsd). When the deployment sets no cap, the trigger's applies on its own. - A value above
PI_MAX_COST_USDis a mistake. When the worker reads the triggers file (PI_TRIGGERS_FILEis set for it), it refuses to load such a file: at startup it will not start, and on a live edit it keeps the cron triggers it had. The receiver reloads webhook triggers on its own and does not know the worker's cap, so it still serves an edited webhook trigger. Its jobs then run under the smaller of the two caps, never the trigger's higher one.pi-dispatch doctornames the trigger either way. - A job with a cap needs a job image that declares the
costCapcapability. The shipped image does. On an older image the job is refused before it costs anything, with reasonjob-image-cost-cap-unsupported. That image's runner would ignore the cap. A deployment that sets no dollar cap anywhere needs no new image. - A chained job (job chaining) keeps its parent's cap.
- No AI tool can set or change it.
dispatch_trigger_addanddispatch_trigger_editrefuse a call that carries it, and an edit leaves it as it is.dispatch_triggersand the panel show it.
Upgrading, and services out of step. The same as for models above: a receiver from before this
release drops maxCostUsd, and its job would run under the deployment's cap or none. The worker refuses
such a job before it spends (trigger-skew, naming the field), when it can read the triggers file. The
check is strict: adding maxCostUsd to a trigger refuses the jobs of it that were already queued
(trigger-skew). Re-run them. Upgrade the worker and the receiver together. pi-dispatch doctor reminds you
of this once when a trigger sets maxCostUsd, as it does for models.
Dollar windows. A deployment can also cap what all its jobs spend per UTC day, Monday week and month:
PI_DAILY_COST_USD, PI_WEEKLY_COST_USD and PI_MONTHLY_COST_USD (or dailyCostUsd, weeklyCostUsd and
monthlyCostUsd in the panel). Each needs PI_MAX_COST_USD.
- Before a job starts, its cap (the smaller of the trigger's
maxCostUsdand the deployment's) is held in every window that is set. A job that does not fit is refused with reasondollar-cap, and nothing is spent. Under an allocation envelope a window bound by the project's share or the envelope total refuses withallocation-capinstead (allocation). - After the run, the hold is replaced by what the job really cost. When that cost is not fully known (the job died before it reported, or some calls could not be priced), the window is charged at least the whole hold, and the measured cost when that is higher.
- A lower
maxCostUsdon a trigger holds less, so more of its jobs fit in a window. - A job whose every allowed model runs on a declared local model server and costs nothing holds nothing
(
docs/egress.md, "Local model servers"). scoped-limits.jsonversion 2 adds the same windows per repo or folder, and per model. A trigger'smodelslist (orPI_ALLOWED_MODELS) decides which model windows its jobs hold their cap in: the listed models only. A job with no list holds its cap in every model window, since it may switch to any model. See scoped limits.
A skill arrives whole. Put references/, templates or scripts next to your SKILL.md. They are
copied into the job container with it. A relative path in your skill will find a file that is actually
there. Three limits matter. Scripts arrive non executable. Everything under /job is read only.
Invoke them as bash scripts/build.sh instead of ./scripts/build.sh. Files that start with a dot are
skipped. This matches how pi's own loader works. A skill directory has a size limit: 256 files, 8 MiB
total, 1 MiB per file, 4 levels deep. A repo that exceeds any of these limits refuses its jobs and
gives a reason naming the cap. This happens before anything is spent. The skill is not quietly copied
in part.
ai-trigger controls a different choice. It answers
which flows a model may fire. It does not answer who may fire a job. Only two paths read it. One is
the model-callable dispatch_run tool. The other is job chaining, which happens when a finished job
requests a follow-up. A cron entry or forge trigger in your reviewed triggers.json runs its flow
either way, because a human already approved that pairing by writing the file. Omitting the line does
not stop a label or comment trigger from firing. Who may fire a forge trigger is a separate gate
entirely. See the five trigger types
above for details.
A flow is one skill. A skill may call other skills. The simplest workflow is just that chain running inside one job. For something more structured, pi extensions stage into the deployment and load in every job container. They are pinned to an exact version and present offline.
How a workflow gets triggered: nothing new fires it. The chain is always the same. Every stage runs inside the one job the trigger produced:
label / comment / PR / cron -> one job, one container -> run.flow | run.command -> the skills it calls,
or a workflow extension
Four basics follow from that shape:
run.flowandrun.commandare the two entry points. A trigger names a flow or a registered command, never a workflow. In a flow job, which stages run is decided inside the job by that skill. In a command job, the named command dispatches directly. Its arguments are fixed in the reviewed file.- A job is not an interactive session. The container hands pi one assembled prompt and reads the exit
line. That one prompt can be a slash command.
"command": "wf"on a trigger dispatches a workflow extension's/wf(in the example below) exactly as a typed one would. There is no model turn in between. In a flow job a workflow starts because the flow's instructions drive it. Or it starts because you also staged a small extension that calls the workflow API from a lifecycle hook. - One trigger is one job, one budget slot, one turn budget. Ten stages share the same
PI_MAX_TURNSand the same per-job token budget. Exhausting either aborts the job as a policy refusal that is never retried. Budget for the whole chain, not per stage. - Whether state survives depends on the trigger kind. A cron or CLI job has your folder mounted read-write. A workflow's own state persists between runs. A forge job gets a fresh clone, so its state does not survive.
Staging is the setup. It happens once on the host. If you already installed the package in pi, that is the whole setup. The stager finds it and pins the exact version your host has.
pi install npm:@juicesharp/rpiv-workflow
pi-dispatch import-pi --with-packages # installs on the host, always --ignore-scripts, into ./pi-global/packages/Declaring a package by hand is still available. You can pin a version different from your host's. Or you can add one your host does not have. An entry here wins over what was discovered:
// pi-packages.json (optional)
{ "packages": [ { "name": "@juicesharp/rpiv-workflow", "version": "2.4.0" } ] }That example is a real pi extension. It chains skills into typed multi-stage workflows with per-stage
output validation and append-only JSONL state. pi-dispatch does not integrate it specially. That is the
point. Any package whose package.json carries a pi manifest stages the same way. One staged
directory can contribute extensions, skills, prompts and themes at once.
What the deployment provides is the plumbing and the limits. No package installs at job time. pi's
resolver runs offline in every job, which is why staging exists. Only exact versions are allowed.
Package code loads last so it can never shadow your repo's own skills. Any extension that tries to
register a dispatch_* tool is dropped. Set "packages": false on any trigger that must not load them. Every
package is printed by name with where it came from. Read that list before you rely on it: it is
everything that runs in every job.
Read where a workflow's own state lives before you build on it. It differs between
a cron job and a forge job. See docs/workflows.md.
{ "triggers": [ { "on": { "type": "cron", "id": "nightly", "pattern": "0 3 * * *" }, "run": { "kind": "local", "folder": "/srv/site", "flow": "tidy", "task": "run the nightly tidy" } }, { "on": { "type": "label", "any": ["pi:frontend"] }, "run": { "kind": "github", "flow": "frontend-fix" } }, { "on": { "type": "comment", "phrase": "@pi" }, "run": { "kind": "github", "flow": "fix" } }, { "on": { "type": "pull_request", "action": ["labeled"], "any": ["pi:review"] }, "run": { "kind": "github", "flow": "review" } }, { "on": { "type": "label", "any": ["pi:fix"] }, "run": { "kind": "gitlab", "flow": "fix" } } ] }