Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine. pi-dispatch runs the pi coding agent as a self hosted background service: on a schedule, or when an issue, comment or pull request arrives from GitHub, GitLab, Forgejo or Azure DevOps, it opens a locked down container, lets the agent do one job against the repo, records what it did and what it spent, and shuts the container down. A durable queue absorbs bursts, spend caps are checked before a single token is spent, and a live admin panel shows everything and can turn the whole thing off.
pi itself is a superb agent with no job queue, no concurrency control, no spend limit, and, by its own README, no permission system. pi-dispatch is exactly that missing operational layer, and nothing else:
- The container is the boundary. Every job runs
--cap-drop=ALL, non-root, ephemeral, with its instructions mounted read-only. That is pi's missing permission system, enforced by Docker. - Spend is bounded before a container starts: a per-job turn budget plus daily, weekly and monthly
caps, checked before a single token is spent. And analyzed after: the panel's COSTS view shows spend
per flow, trigger, model, day and repo, what a subscription actually saves, and what a flow would
cost on another model (
docs/costs.md). - The image is yours to shape. Bake a project's toolchain into
image/Dockerfile; it ships Playwright and Chromium, so a flow can build a frontend, screenshot it, and iterate on the rendered result. Any trigger can name its own image withrun.image(docs/job-image.md). - Three ways in, one job. A CLI command, a cron schedule, or a forge event: same queue, same box, same panel. The full trigger reference is the next section.
- Your project steers it through pi's native
.pi/skillsand persona, from your committed files, over a small immutable safety floor the agent cannot remove.
Reach for pi-dispatch when the work is recurring or event driven, the kind of agent loop you want running without you:
- a nightly job that triages new issues, updates a report, or tidies a backlog;
- "label an issue
aiand a fix PR appears", for your team's everyday small fixes; - a follow up loop that answers review comments on the PRs the agent itself opened;
- several repos and several such loops, sharing one queue, one budget, one panel.
For a one off interactive session on your own machine, plain pi is enough; pi-dispatch earns its keep the moment an agent runs while nobody is watching the terminal.
Running agents this way is a design, observe, tune loop (some call it workflow or context engineering;
here it is just how the pieces fit). You design loops as triggers plus committed skills; the
graph shows the loops you actually built (what triggers what, what chained to what, where a skill's
own text says it might loop, docs/graph.md); and insights prices them (what each
trigger and flow costs, whether a subscription pays off, drawn as charts beside that same topology,
docs/insights.md). One /dispatch insights html gives you the whole picture as a
single file your browser opens from disk:
You need Docker, Node 22.19 or newer, and a provider API key (Anthropic, OpenAI, and about 30 others).
You already run pi, so let the console do everything:
pi install npm:@edgehero/pi-dispatch-admin # then, inside pi: /dispatchWith nothing configured, /dispatch takes you straight into guided setup: it creates a deployment
folder, installs the pinned runtime, runs the same consented up pass described below in your
terminal, optionally installs the worker as a service and the receiver as your trigger edge, and lands
you in the admin panel, with an optional first trigger for the repo you are sitting in. Every step
shows what it will do, asks first, and can be declined; nothing is written into your repo and no
credential passes through a dialog.
The same setup as plain commands. No clone needed:
mkdir my-dispatch && cd my-dispatch
npx @edgehero/pi-dispatch up # one consented pass: pulls the job image, starts Valkey,
# scaffolds the config files, runs the doctor preflight.
# Every docker action shows its command and asks first.
# edit .env and set your provider key. Already logged into pi with an API key? Leave it blank.
npx @edgehero/pi-dispatch worker # terminal 1: drain the queue
npx @edgehero/pi-dispatch run ./my-project --task "add type hints" --flow tidy # terminal 2: first jobThe worker mounts your folder into a container and pi edits it in place. It refuses a dirty git tree
unless you pass --force, because there is no undo. Commit first.
Prefer a clone? Same commands, unbundled (git clone + npm ci, then npx pi-dispatch init, doctor,
worker). A clone is required for one thing only: building a custom job image, since the Dockerfile
needs this repo as build context. Jobs launch with --pull=never, so pull or build every image before
you run; a name the host does not have is refused before the job costs anything.
Naming heads-up. The published package is scoped:
@edgehero/pi-dispatch(binpi-dispatch). The bare npm namepi-dispatchbelongs to an unrelated package (see License), so outside a checkout always use the scoped form.
This is what starts a job. Everything further down is the box those jobs run in and the panel you watch
them from. All standing triggers live in one triggers.json, read live by the worker (cron) and the
receiver (forges), and editable from the panel:
pi-dispatch is the trigger layer. Every entry is one { on, run } pair: on is what fires it, and
run is the skill it runs (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 | 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 |
the PR title and body, plus the review body when a review fired it |
Every type also needs run.kind (local for cron, else the forge) and run.flow. 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 task. Azure label and comment
triggers need run.repository, because a work item belongs to a project and names no repository.
Two matching behaviours worth knowing before you arm 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. - Label triggers match differently per forge. GitHub and Forgejo match the issue's whole current label set, so reopening an already-labelled issue, or adding an unrelated label to one, fires again. GitLab and Azure match only the labels that event added, which is exactly why they do not re-fire that way.
action words are each forge's own vocabulary, 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 |
labeled |
review_submitted is the pull_request_review event's submitted action, so a formal Approve or Request changes starts a job. It is gated on the reviewer's permission, never the PR author's, so a collaborator reviewing a stranger's fork PR runs and a stranger reviewing their own PR does not |
gitlab |
open update reopen approved |
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 |
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 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, so 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 arm it. A Comment type review whose remarks are all line comments
and whose summary box is empty starts nothing, because 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. And the bot loop guard knows only our own identity, so 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, and
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, none of them per-trigger, are the signature check, the bot-loop guard, that
permission check, dedup, quiet hours, the image preflight, branch protection, and the spend caps.
Each is a deliberate file-only edit (no panel key, no AI tool, because each one changes what code runs or what it costs):
"image"names the container image for that trigger's jobs; absent meansPI_JOB_IMAGE. The image decides what is in the box, never what the box can do: the isolation flags are the worker's, always (docs/job-image.md)."packages": falseopts one trigger out of the staged third-party pi packages, which is also how a workflow extension is withheld from one flow (docs/workflows.md)."skillsDir"points at a directory of skills on the worker host, in the same<name>/SKILL.mdlayout as your own~/.pi/agent/skills. They are copied into that trigger's jobs and layered under the repo's own.pi/skillsand over the global overlay, so 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, to A/B two versions of a flow across two triggers, or to keep a private flow out of a public repo's history (docs/global-pi-overlay.md)."replicas": 2(GitHub only) races independent sandboxes on the same event and opens one PR per replica. 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 rather than from the issue, so "the tests run with pnpm here" applies to every run of that trigger without being committed to the repo or pushed into the deployment wide persona. Cron triggers usetaskinstead, which is the same text in the same place."resume": truecontinues the session that opened the PR (docs/sessions.md)."github": trueon a cron trigger mints the same per-job GitHub token the webhook path gets, so a scheduled flow can usegh.
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 and both services reload it live. Every local job
also receives a read-only /job/event.json (source, folder, HEAD sha; cron adds its id, pattern and
schedule instants), so a scheduled flow can triage only what changed since its last run.
Pause a specific repo or folder between certain times (recurring, weekday- or date-bounded, timezone-aware) and resume automatically. A paused job is deferred, never dropped, and spends nothing:
{ "windows": [
{ "scope": "acme/web", "from": "22:00", "to": "06:00", "tz": "Europe/Amsterdam", "days": ["mon","tue","wed","thu","fri"] }
] }Manage them with w in the panel or PI_PAUSE_WINDOWS_FILE by hand
(docs/pause-windows.md).
A flow is a pi skill committed to the target repo at .pi/skills/<flow>/SKILL.md. That file is the
custom prompt; a trigger only names which flow to run, so different repos define the same flow name their
own way:
<!-- .pi/skills/tidy/SKILL.md -->
---
name: tidy
description: Format, fix lint, and tighten types across the repo.
ai-trigger: allow # opt-in for AI-INITIATED runs only (the dispatch_run tool, job chaining). Default deny.
---
Run the formatter and linter and fix what they report; tighten obvious type holes.
Keep the diff minimal and open a PR titled "tidy: <what changed>". Do not change behavior.The flow is the standing instructions; the task is the one-off ask (your --task, a trigger's
task, or the issue/comment text itself). Flows are read from the default branch, so commit and
merge a flow before a trigger can use it. That merge is the repo's consent.
A skill arrives whole. Ship references/, templates or scripts beside your SKILL.md and they are
copied into the job container with it, so a relative path in your skill resolves to a file that is
actually there. Three limits worth knowing. Scripts arrive non executable, because everything under
/job is read only, so invoke them as bash scripts/build.sh rather than ./scripts/build.sh. Files
whose names begin with a dot are skipped, matching pi's own loader. And a skill directory is bounded (256
files, 8 MiB, 1 MiB per file, 4 levels deep): a repo past any of those refuses its jobs with a reason
naming the cap, before spending anything, rather than quietly copying part of the skill.
ai-trigger gates a different axis, and it is worth being precise about which. It answers which
flows a model may fire, not who may fire a job. Only two paths read it: the model-callable
dispatch_run tool and job chaining (a finished job requesting a follow-up). A cron entry or a forge
trigger in your reviewed triggers.json runs its named flow whether or not the frontmatter carries the
line, 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,
described under Triggers above.
A flow is one skill, and a skill may call other skills, so the simplest workflow here is just that chain running inside one job. For something more structured, pi extensions stage into the deployment and load in every job container, pinned to an exact version and present offline.
How a workflow gets triggered: nothing new fires it. The chain is always the same, and every stage runs inside the one job the trigger produced:
label / comment / PR / cron -> one job, one container -> run.flow (a skill) -> the skills it calls,
or a workflow extension
Four basics follow from that shape:
run.flowis the only entry point. A trigger names a flow, never a workflow. Which stages run, and in what order, is decided inside the job by that skill.- A job is not an interactive session. The container hands pi one assembled prompt and reads the exit
line, so a workflow extension's slash command (
/wf, in the example below) has nobody to type it. In a job a workflow starts because the flow's instructions drive it, or 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, so a workflow's own state persists between runs. A forge job gets a fresh clone, so it does not.
Staging is the setup, and 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, for pinning a version different from your host's, or for 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, and that is
the point. Any package whose package.json carries a pi manifest stages the same way, and one
staged directory can contribute extensions, skills, prompts and themes at once.
What the deployment provides is the plumbing and the limits: no network at job time (so nothing installs
inside a job, which is why staging exists), exact versions only, package code loaded last so it can
never shadow your repo's own skills, any extension that tries to register a dispatch_* tool dropped,
and "packages": false on any trigger that must not load it. Every package is printed by name with where it
came from, because that list is the moment to vet what will run in every job.
Where a workflow's own state lives is the part worth reading before you build on it, because it differs
between a cron job and a forge job: docs/workflows.md.
flowchart LR
CLI["pi-dispatch run ./folder --task ..."] -->|enqueue| Q[("Valkey + BullMQ<br/>the wait-list, AOF")]
Q --> B{"under the daily cap<br/>and turn budget?"}
B -->|no| STOP["refused before any spend"]
B -->|yes| C["docker run --rm: one ephemeral container<br/>--cap-drop=ALL, non-root, no-new-privileges<br/>/job read-only, /workspace = your folder"]
C --> PI["pi + Playwright + git + gh<br/>guardrails + your .pi/"]
PI -->|"edits in place"| F[("your folder")]
Every trigger flows through this same path: a container boundary, spend checked before the container
starts, nothing dropped. Read SECURITY.md before you rely on it; it states plainly what
is and is not defended. Recorded tokens and cost are process-wide (subagent sessions included), and the
per-job token budget is enforced against that same total.
Give every job your host pi setup (custom models, global skills, a persona), layered under each repo's
own .pi/. The repo always wins on conflict:
pi-dispatch import-pi # stage a credential-free copy of ~/.pi/agent into ./pi-global
# then set PI_GLOBAL_PI_DIR in .env; doctor verifies it carries no credentialThree properties worth knowing, each with a full reference:
- Credentials never enter the overlay.
import-pirefuses amodels.jsonwith a literal key and never copiesauth.json. When the worker's env has no provider key, it reads your API key host-side from~/.pi/agent/auth.jsonand injects it per job. OAuth and subscription logins are refused; an unattended service needs an API key with a spend limit (docs/global-pi-overlay.md). - Third-party pi packages are pinned once, declinable per trigger.
import-pi --with-packagesstages what you installed withpi install, at the exact version your host has, plus anything you pinned by hand inpi-packages.json(always--ignore-scripts). Opt any trigger out with"packages": false, and use--no-host-packagesto stage only what you declared. This is also how a workflow extension reaches a job (docs/workflows.md). - What a repo declares is never installed. A serviced repo's
.pi/extensionsdo run, because the checkout is the default branch and merging is the gate, but nothing installs packages on a repo's say so: that would put third party install time code next to a live forge token (SECURITY.md). - Sessions can continue instead of restarting.
"resume": trueon a trigger makes follow-up jobs continue the session that opened the pull request. It persists the full transcript to disk, which is a real disclosure: readdocs/sessions.mdbefore enabling it.
pi-dispatch service render # show the unit it would install: real node path, real repo root
pi-dispatch service install # user-level: LaunchAgent (macOS) / systemctl --user (Linux) / nssm (Windows)
pi-dispatch service status # which unit exists, in which scope, and whether it is running
pi-dispatch service restart --drain # pause, wait until nothing is in flight, restart, resumeinstall renders the deploy/ templates and installs without sudo (--system on Linux
prints the sudo commands instead of running them; --receiver installs the receiver unit when the
receiver package is installed alongside). Units anchor on your deployment folder: WorkingDirectory
and .env point at the folder you run the command from, and the exec paths point at the installed
package, so the same command is correct from a checkout and from an npm install. It refuses a second worker unit in another scope: one worker per docker daemon, because
the boot reaper would treat the other's containers as strays. Honesty notes: on macOS and Windows the
service is login-scoped, because Docker Desktop is; and a policy refusal (exit 2) never relaunches, on
any OS, so no supervisor loops against a paid provider. The templates remain hand-editable examples if
you prefer to adapt them directly.
Steer the running worker without stopping it, from any terminal:
pi-dispatch pausestops taking new jobs. Durable: it lives in the queue and survives restarts. Jobs still enqueue; they wait.pi-dispatch resumetakes jobs again.pi-dispatch statusprints the counts.
The dashboard shown at the top of this README is a pi extension: it loads into your own interactive
pi session. No daemon, no web app, no network port. Beside the dashboard, /dispatch graph renders the
whole trigger and flow topology: what triggers what, what chained to what in the recorded runs, what a
skill's own text says it might chain to, plus orphan skills and dangling triggers. It comes as a
dashboard view (g), as plain text, and as /dispatch graph html, a self contained page your browser
opens from disk (still no server and no port) with the topology drawn Node-RED style
(docs/graph.md). Trigger rows carry their schedule (next fire, or overdue) and
their window spend, and /dispatch insights html combines this topology with the cost analytics on
one page (docs/insights.md, shown above).
pi install npm:@edgehero/pi-dispatch-admin # then, in pi: /dispatchNo deployment yet? The console builds one. When /dispatch finds nothing (no config, no env, queue
unreachable) it takes you straight into /dispatch setup, described in the Quickstart above: an
opening choice, a deployment folder, the pinned runtime, a Docker check, pi-dispatch up in your
terminal, an optional worker service, an optional trigger edge (receiver service, compose profile, or
the polling command), and an optional first trigger for this repo. Every step asks first and can be
declined; nothing is written into your repo, and no credential passes through a dialog. Setup writes a
small deployment pointer (~/.pi/agent/pi-dispatch-deployment.json, paths only, never credentials)
so the panel finds the deployment from any directory afterwards. Your own env vars always win over it,
key by key, and when the deployed runtime falls behind the console's pin, one notice points you back at
/dispatch setup to upgrade. A configured deployment whose queue is merely down keeps the unreachable
banner: setup is offered when there is nothing, never over an outage.
Inside the panel: p/r pause and resume the queue, arrows and Enter drill into triggers and runs,
a/e/x add, edit and delete triggers (validated, atomic, reloaded live by both services), s edits
a limit, w manages quiet hours, c opens the COSTS view (its f key cycles spend by flow, model,
trigger and repo). Enter on a run opens its full record:
The same surface exists as plain commands (/dispatch status | runs | logs | budget | triggers | costs | graph | insights | run | pause | resume | set | unset), all local, no model involvement.
The package also ships the operate-pi-dispatch skill, so your assistant knows the deployment's
tools: ask in plain language ("raise the daily cap to 30", "add a nightly tidy trigger for /srv/site").
Reads need no confirmation. Every config write pops an operator confirmation the model cannot answer,
and refuses when no operator is present, so a prompt-injected session cannot raise your cap or add a paid
trigger. One tool is deliberately not money-safe: dispatch_run enqueues a paid run without a confirm,
bounded instead by six independent limits (folder allowlist, the committed ai-trigger: allow opt-in,
the dirty-tree refusal, no spend knobs, a rate limit, and the daily cap). Raw job logs render in the
overlay only and never enter model context.
Label an issue, and a container works it on a fresh clone, opens a PR, and comments back:
flowchart LR
GH["GitHub repo<br/>issue labeled, @pi comment, PR, or review"] -->|"webhook, HMAC-signed"| R
subgraph EDGE["receiver/ (public edge, binds 0.0.0.0)"]
R["verify raw-body HMAC (401 on mismatch)<br/>filter: label allowlist, author gate, bot-loop"]
end
R -->|"enqueueGitHubJob (jobId = gh-<delivery>)"| Q[("Valkey + BullMQ<br/>pi-jobs, AOF, 31d+ retention")]
subgraph HOST["worker/ (host process)"]
W["mint scoped token, refuse an unprotected branch,<br/>hardened clone at the default-branch SHA, run container"]
end
Q --> W
W -->|"docker run --rm"| C["job container: the agent commits,<br/>pushes --force-with-lease, gh pr create, comments"]
C -->|"GITHUB_TOKEN via env only, never merges"| GH
- Only a collaborator's label,
@picomment or formal review starts a job; the label is the approval step. PR triggers (label, comment, auto on open/update, or a submitted review) gate the auto path on the PR author being a collaborator, so a fork PR from a stranger never auto-fires, and gate a review on the reviewer instead, so a collaborator reviewing that same fork PR does. - The per-job token is repo-scoped and short-lived, and honestly: it can merge, because GitHub gates push and merge behind the same scope. Branch protection on your default branch is the real control, and the worker refuses an unprotected repo before any spend.
- The checkout is always the base repo at its default-branch SHA, never a PR branch. Landing a commit on your default branch is enough to run code in a job container; issue and comment text never is. It stays data.
Credentials in one click: pi-dispatch setup github runs GitHub's App Manifest flow against your own
loopback. One browser click mints the App id, private key and webhook secret; every .env line is shown
before one consent, the key lands with mode 0600, and no secret is ever printed. The App is the
strongest auth source (per-repo one-hour tokens); gh and fine-grained PATs also work
(GITHUB_AUTH_SOURCE).
Three ways to run the trigger edge, pick one (or let /dispatch setup walk you through the choice,
which is what it offers right after the credentials step):
- Webhook receiver on the host (lowest latency):
npx pi-dispatch-receiverfrom your deployment folder, orpi-dispatch service install --receiverto run it as a user-level service. Your reverse proxy or tunnel does the public exposure. - Webhook receiver in a container:
docker compose -f deploy/docker-compose.yml --profile receiver up -druns the prebuiltghcr.io/edgehero/pi-dispatch-receiverbeside Valkey, triggers mounted read-only, no docker socket anywhere. - No public URL at all:
pi-dispatch-receiver pollfetches issue events, comments and PRs over TLS with your own credential (conditional requests, nearly free against the rate limit). Same gates, same queue, about 60 seconds of latency, zero public surface. Pair withsetup github --no-webhook. A fresh poller starts from now and never replays old labels.
Same machinery, per-forge correctness differences, each with a full setup doc:
- GitLab (
docs/gitlab.md): webhook at/gitlab, project token withapiscope. A GitLab label is not an approval, so every trigger is gated on the actor's resolved access level (Developer or above). Labels fire on the diff, not the state. Needs GitLab 17.4+ for retry-stable dedup. Self-hosted works viaGITLAB_URL. - Forgejo / Gitea (
docs/forgejo.md): webhook at/forgejo. Transport is byte-compatible with GitHub's; actions are Forgejo's own words (label_updated,synchronized); every trigger is gated on the actor's resolved repository permission. - Azure DevOps (
docs/azure-devops.md): Service Hook at/azure. The weakest transport of the four (no HMAC, no signed timestamp), and the doc says exactly how. Needs a dedicated identity,run.repositoryon triggers, and its own job image (image/Dockerfile.azure).
Every job writes a durable, id-only record under PI_LOGS_DIR (never issue or comment text; raw logs
are opt-in via PI_CAPTURE_JOB_LOGS=1 and stay host-side). Each record carries a per-model usage
ledger, which is what the COSTS view, /dispatch costs, and the what-if re-pricing fold over. Declare
what your subscriptions cost in subscriptions.json and the screen shows whether they actually save
money; without it, zero-rate runs show $0 (unrated), never "free" (docs/costs.md).
A finished run's workspace is kept for a bounded window (24h by default), so you can re-open it:
pi-dispatch sandbox gh-12345 --publish 3000 # a fresh container on that run's workspace, no credentialsSame image, same isolation, no minted token, no provider key. The agent is not running; you are. From
the panel, press b on a run. A retained directory holds the run's clone plus its issue text, so read
docs/sandbox.md before extending retention.
vs the Claude Code GitHub Action: for pure GitHub automation the action does label-triggered issues for a tenth of the effort. pi-dispatch is for the narrower case where you run pi on your own hardware and want a real queue, a container boundary, and flows against local folders, not just GitHub repos.
vs hosted routines and /loop: for "run a prompt on a schedule" they are simpler; nothing to host.
pi-dispatch's cron trigger is for recurring work that needs an environment a hosted routine cannot give
it: a project's exact toolchain, or the baked-in Playwright + Chromium that lets a scheduled flow build
a frontend, screenshot it, and iterate until it renders right.
Everything this README describes is built and running: four forges (GitHub, GitLab, Forgejo and Gitea, Azure DevOps), cron and CLI jobs, the durable queue with spend caps in four windows, resumable sessions, replica runs, per trigger job images, quiet hours, cost analytics, resurrectable sandboxes, the service installer for all three platforms, and the admin console with guided first run setup.
The design is specified in specs/, and the specs are the source of truth rather than a summary
of the code: start with specs/constitution.md for the non-negotiables,
specs/design.md for the decisions and what was rejected, and
specs/interfaces.md for the file and container contracts. Every spec file ends with
a revision history that records what changed and why, including the corrections.
Working on this repo with an AI agent? CLAUDE.md is the short version of what matters here.
PRs welcome. Sign off your commits (git commit -s); this project uses the
DCO, not a CLA. If you change behaviour, the spec changes with it:
specs/ is the source of truth, and a PR that violates a CONST-* entry will be asked to justify the
constraint first, not the code.
MIT. See LICENSE. Built on pi by Mario Zechner, which does the actual hard part.
Not affiliated with the unrelated npm package
pi-dispatch, a pi extension for rotating ChatGPT Codex OAuth accounts. Same name, different thing. This project publishes scoped packages only:@edgehero/pi-dispatch(worker + CLI),@edgehero/pi-dispatch-receiver, and@edgehero/pi-dispatch-admin(the console). The bare name is theirs.



{ "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" } } ] }