pi-dispatch: run the pi coding agent as a self-hosted
  service

# pi-dispatch **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](https://github.com/earendil-works/pi) is an open source coding agent that you normally run in a terminal. pi-dispatch runs it as a background service on your own machine or server. A job starts on a schedule, from the command line, or when an issue, comment or pull request arrives from a forge (GitHub, GitLab, Forgejo or Azure DevOps). Each job gets its own locked down container. The agent does one piece of work there, pi-dispatch records what it did and what it cost, and the container is thrown away. pi has no job queue, no concurrency control, no spend limit and, by its own README, no permission system. pi-dispatch adds exactly that layer and nothing else. It is not another agent to compare with the one you use. It is the queue, the budget and the box around the pi you already run, steered by the `.pi/` setup your repo already has. ![The /dispatch dashboard: live queue state, day, week and month job meters with a daily token counter, the dollar windows, the triggers pane with a portfolio trigger, pause windows, scoped limits with a project row and a model row showing their dollar caps, held and failed jobs, and the interactive runs list with each run's project and the u hosts key on its divider, in one framed terminal view](docs/images/dispatch-dashboard.svg?v=2026-10-22-710) What you get: - **A container around every job.** Each job runs non root, with every Linux capability dropped, in a container that is deleted afterwards. Its instructions are mounted read only. That container is pi's missing permission system. It runs on Docker or Podman ([`docs/backends.md`](docs/backends.md)). - **Spend limits that apply before anything is spent.** A turn limit per job, job and token caps for the whole deployment, and dollar caps per day, week and month. Each job can also have a dollar cap of its own: the job is stopped before any model call that could take it past that cap. Caps can apply per repo, folder, model or project too. A job over a cap is refused before its container starts. The insights page then shows spend per flow, trigger, model, repo, project and day ([`docs/costs.md`](docs/costs.md)). - **Projects and a budget split.** Group repos and folders into projects and cap each project as one. Set a dollar total per day, week or month with a floor per project, and a portfolio manager flow can move money between projects inside it with no keypress. Every move is logged and can be reverted ([`docs/projects.md`](docs/projects.md), [`docs/allocation.md`](docs/allocation.md)). - **A size per project, inside a budget per machine.** Each project's jobs get the memory and CPUs its row sets (4 GB and 2 CPUs by default). A job starts only when its size fits beside what already runs on that machine, and room is kept for a waiting big job, so a stream of small ones cannot starve it. `pi-dispatch doctor` and the panel suggest sizes from what the runs used, and you apply them ([`docs/sizing.md`](docs/sizing.md)). - **Model allow lists.** A trigger's `run.models`, or `PI_ALLOWED_MODELS` for the whole deployment, names the models a job may use. The job is stopped before it calls any other model ([`docs/triggers.md`](docs/triggers.md)). - **An image you control.** The job image ships git, `gh`, Playwright and Chromium, so a job can build a frontend and look at it. Add your own toolchain in [`image/Dockerfile`](image/Dockerfile), or give one trigger its own image ([`docs/job-image.md`](docs/job-image.md)). - **Three ways in, one path.** A CLI command, a cron schedule or a forge event all use the same queue, the same container and the same panel ([Triggers](#triggers)). - **Your repo steers the agent** through pi's own `.pi/` folder: skills (Markdown instruction files the agent follows) and a persona. A small safety floor sits under them that the agent cannot remove. ## When to use it Use pi-dispatch when the work is **recurring or event driven** and you want it to run without you: - a nightly job that triages new issues, updates a report or tidies a backlog; - "label an issue `ai` and a fix PR appears", for small everyday fixes; - a loop that answers review comments on the PRs the agent opened; - several repos and several such loops sharing one queue, one budget and one panel. On a forge you stay in control at both ends. A job starts only when someone with write access to the repo asks for one, with a label, an `@pi` comment or a review. What comes back is a pull request that you review and merge yourself. pi-dispatch never merges anything, not on green CI, not under any condition ([details](#github-automation)). It removes the terminal babysitting, not you. For a one off session on your own machine, plain pi is enough. pi-dispatch is for agents that run while nobody watches the terminal. Two views help you tune what you built: the **graph** shows which triggers start which flows (the skill a job runs, see [Flows](#flows-the-custom-prompt-a-trigger-runs)) and what chained to what ([`docs/graph.md`](docs/graph.md)), and **insights** shows what each of them costs, whether a subscription pays off, and how busy each machine was ([`docs/insights.md`](docs/insights.md)). Both are on one page, `/insights` in the web console (`pi-dispatch-console serve`, see [`docs/console.md`](docs/console.md)), and `/dispatch insights` prints its address: ![The insights page: KPI tiles, budget dials, the budget split with each project's share, spend and floor and the split's history, a plan verdict, daily and cumulative spend charts, per flow trends, five breakdowns (flow, trigger, model, repo and project), the job sizes, how busy each host was with a chart of the jobs running at once against its slots and hatched stretches with no data, and the trigger and flow topology with spend shown on each trigger](docs/images/insights-view.png?v=2026-10-22-710) ## Quickstart You need: - **Docker**, or **Podman** (rootful through its Docker API, or rootless on Linux with `PI_BACKENDS=podman`; which to pick: [Docker or Podman](#docker-or-podman)); - **Node 22.19 or newer**; - an **API key** for a model provider, which pi uses inside each job (see [Providers and models](#providers-and-models)); - a machine that can run a container runtime all the time (see [Where it can run](#where-it-can-run)). ### From pi (the default route) If you already use pi, let the console set everything up: ```bash pi install npm:@edgehero/pi-dispatch-admin # then, inside pi: /dispatch ``` With nothing configured, `/dispatch` starts a guided setup. It creates a deployment folder, installs the exact pi-dispatch version the console expects, runs the same `up` pass shown below, and can install the worker as a service and a receiver for forge events. It ends in the admin panel, with an optional first trigger for the repo you are in. Every step says what it will do and asks first. Nothing is written into your repo, and no credential passes through a dialog. ### Servers and headless The same setup as plain commands. No clone needed: ```bash mkdir my-dispatch && cd my-dispatch npx @edgehero/pi-dispatch up # one pass, asks before every container action # edit .env and set your provider key (leave it blank if pi already holds your API key) npx @edgehero/pi-dispatch worker # terminal 1: runs jobs npx @edgehero/pi-dispatch run ./my-project --task "add type hints" --flow tidy # terminal 2: first job ``` `up` pulls the job image and starts Valkey (the queue's database) with its own password, so no other account on the host can read the queue. It starts the egress proxy that jobs reach the network through. It writes the config files (`init` on its own does only that part) and fills in `.env` lines that have no value yet, without a prompt and without ever overwriting one. Then it runs the `doctor` preflight: ![pi-dispatch init creating the deployment files, then the next steps](docs/images/cli-init.svg?v=2026-10-02) ![pi-dispatch doctor: one line per check, including the three egress policy probes, two warnings with their fixes, and the ready verdict](docs/images/cli-doctor.svg?v=2026-10-02)
What a whole up pass prints ![pi-dispatch up in an empty folder: the files init writes, the generated secrets, Valkey started with its password, the egress proxy network and container with its model endpoints rules mounted, doctor's checks folded into one counted line, and the closing summary](docs/images/cli-up.svg?v=2026-10-02)
A local job edits your folder **in place**, and there is no undo. `pi-dispatch run` refuses a folder with uncommitted changes unless you pass `--force`. The folder must be the root of a git repository with at least one commit, and `run` refuses any other folder before it queues anything. A cron trigger's folder is not checked when it fires, so keep that folder committed yourself. A clone of this repo (`git clone`, `npm ci`, then `npx pi-dispatch init`, `doctor`, `worker`) is only needed to **build a custom job image**. Jobs start with `--pull=never`, so pull or build every image first. A job that names an image the host does not have is refused before it costs anything. > **Naming heads up.** The published packages are scoped: `@edgehero/pi-dispatch` (the `pi-dispatch` > command). The bare npm name `pi-dispatch` belongs to an unrelated package (see [License](#license)), so > outside a checkout always use the scoped name. ## Where it can run pi-dispatch is a long running worker that starts containers, so it runs on a machine where it can do both: Node 22.19 or newer beside a container runtime (Docker, or Podman), on the same host. | Where | Works? | |---|---| | A Linux server or VM you control (a VPS, a cloud VM, a home server) | **Yes.** The main target. Run the worker as a service ([Run as a service](#run-as-a-service)). | | Your own Mac or Windows machine with Docker Desktop | **Yes**, while you are logged in, because Docker Desktop is. | | Several machines sharing one queue | **Yes** ([`docs/multi-host.md`](docs/multi-host.md)). | | Serverless and app platforms (Vercel functions, Netlify, Cloudflare Workers, a PaaS without a container runtime) | **No.** There is no container runtime to start jobs in, and nowhere for a worker that never stops. | | Hosted sandbox services as the place jobs run (Vercel Sandbox, E2B, Modal, Daytona and similar) | **Not yet.** A job runs only on the two backends below. A new venue needs an adapter, and [`docs/backends.md`](docs/backends.md) is the contract it would implement. | | A Docker or Podman daemon on another machine (`DOCKER_HOST=ssh://...`) | **No.** Job files are mounted from the worker's own disk, which that machine does not have, and it would receive every job's provider key and forge token. | Only the receiver needs to be reachable from the internet, and only for webhooks. Behind a firewall, use the receiver's `poll` mode instead, which needs no public URL ([`docs/github.md`](docs/github.md)). ### Docker or Podman Jobs run in one of two backends, both on the worker's own host: - **`local`**, the default: the Docker daemon (or rootful Podman through its Docker API). Easiest to set up, and the only choice on macOS and Windows. The worker needs access to the daemon's socket, and that access is equivalent to root on the host. - **`podman`**: rootless Podman, as the worker's own account, on Linux. No daemon runs as root, and the worker needs no root equivalent group. Each job runs as the worker's own uid in that account's own container store, so a container escape lands in an unprivileged account. Valkey, the egress proxy and the worker run as systemd user units. Pick it for a shared or security sensitive Linux server. To use rootless Podman: run the worker as an ordinary account (not root) with its own subordinate ids, turn on linger (`sudo loginctl enable-linger `) so its services run without a login, set `PI_BACKENDS=podman` in `.env`, pull the job image with `podman`, then run `pi-dispatch up`, `pi-dispatch service install` and `pi-dispatch doctor --live`. The full, measured setup, and what is refused (Podman machine on macOS and Windows, rootful or remote Podman on this venue, a root worker), is in [`docs/podman.md`](docs/podman.md). The NVIDIA OpenShell venue (`PI_BACKENDS=openshell`) is set up by the same two commands: `pi-dispatch up` checks its gateway (offering to start and register it, never to install OpenShell), puts the job image into the gateway's engine and starts no egress proxy, since the gateway's policy is that venue's egress control, and `pi-dispatch service install` makes the worker's unit start the gateway's own unit ([`docs/backends.md`](docs/backends.md#the-openshell-venue)). ## Providers and models **pi-dispatch itself needs no AI key.** The queue, the caps, the containers and the panel run no model. The key is for pi, the agent that runs inside each job, and the worker hands it to each job container. pi-dispatch works with most providers pi supports: Anthropic, OpenAI, Google, Groq and about thirty more (the exceptions are below). Pick the default in `.env`: ```bash PI_PROVIDER=anthropic # an exact model version keeps cost predictable PI_MODEL=claude-sonnet-5-5 ANTHROPIC_API_KEY=... ``` Override them for one job with `--provider` and `--model`. The name of the key variable is pi's, not ours: the worker asks pi which variable your provider reads (`GEMINI_API_KEY` for Google, for example), and `doctor` checks that it is set. - **Already logged into pi with an API key?** Leave the key blank. The worker reads it from `~/.pi/agent/auth.json` on the host and passes it to each job. Set `PI_AUTH_FROM_PI=0` to turn that off. - **Not supported from your pi login:** an OAuth or subscription login, or a stored key written as `!command` or `$VAR`. They expire or need a shell, and a service needs an API key with a spend limit. These are refused with a reason before anything is spent. An `ANTHROPIC_OAUTH_TOKEN` or `ANTHROPIC_AUTH_TOKEN` set in the environment is still sent to the job, and `doctor` only warns about it: pi reads either one before `ANTHROPIC_API_KEY`. - **Not supported at all:** providers that do not use a single key variable (an AWS profile for Bedrock, for example). The job is refused before anything is spent. - **A custom provider** goes in your pi `models.json`, staged with `import-pi`. As a job's main provider it runs when a local model server that takes no key serves every one of its models ([`docs/egress.md`, "Local model servers"](docs/egress.md#local-model-servers)). For any other model the agent uses, name its key in `PI_FORWARD_ENV` ([`docs/global-pi-overlay.md`](docs/global-pi-overlay.md)). ## Triggers **A trigger is what starts a job.** All standing triggers live in one `triggers.json`. The worker reads it live for cron jobs, the receiver reads it live for forge events, and the panel can edit it. Each entry is an `{ on, run }` pair: **`on` says what fires it**, and **`run` says what it runs**. ```jsonc { "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" } } ] } ``` ### The five trigger types: what fires each one, and what it runs | `on.type` | Fires when | The agent's task | |---|---|---| | `cron` | your schedule | `run.task` from the file | | `label` | a label is added to an issue (or an Azure work item) | the issue title and body | | `comment` | a comment contains your phrase, such as `@pi` | the comment plus the issue | | `pull_request` | a PR or MR event, a submitted GitHub review, or a close | the PR title and body | | `issue` | an issue closes | the issue title and body | `run` names either a `flow` (a skill in the repo, see [Flows](#flows-the-custom-prompt-a-trigger-runs)) or a registered pi `command`. **Who may fire a trigger is decided by your forge, not by pi-dispatch.** On GitHub only collaborators can add a label, so the label is the approval. GitLab, Forgejo and Azure check the actor's permission through their APIs, because a label proves less there. [`SECURITY.md`](SECURITY.md) states each forge's rule. Every field, each forge's action words and the review trigger's caveats are in [`docs/triggers.md`](docs/triggers.md). ### Close triggers and one-shots A close can run a job exactly once: `number` narrows the rule to one issue or PR, and `once: true` spends it after one run. How spending works, and how to re-arm a rule: [`docs/triggers.md`](docs/triggers.md#close-triggers-and-one-shots). ### Optional `run` fields A trigger can have its own image, vault secrets, MCP servers, removed tools, standing instructions, replicas, a resumed session or a wait condition. Each one is a file edit only, never a panel key or an AI tool, because each changes what code runs or what it costs: [`docs/triggers.md`](docs/triggers.md#optional-run-fields). A whole project can also get one environment from your secrets manager, granted once in `triggers.json` and read fresh at each job start ([`docs/secrets.md`](docs/secrets.md#recipe-d-one-environment-for-a-whole-project)). ### Quiet hours Pause a repo or folder between set times, by weekday and timezone. A paused job waits and spends nothing. Manage them with `w` in the panel ([`docs/pause-windows.md`](docs/pause-windows.md)). ### Scoped limits Cap how many jobs a repo or folder may run per day, week or month, and how many at once. Over a cap the job is refused before any spend; over the concurrency ceiling it waits. Local jobs also have a fixed guard: one job per folder at a time, because two agents editing one working tree race each other with no gate and no undo. The guard lives in the worker process, and one worker per container daemon is the supported setup. Version 2 of the file adds dollar caps per day, week and month for a repo, a folder or a model. A bare `acme/web` covers that repo on every forge. Write it with its forge, `github:acme/web`, to limit it on that forge only. Manage limits with `m` in the panel ([`docs/scoped-limits.md`](docs/scoped-limits.md)). Group repos and folders into projects, recorded per run, shown by project in the cost views and the panel (`j`), and capped as one, with [`docs/projects.md`](docs/projects.md). Set a dollar total for a day, a week or a month, with a floor per project, and an agent may move money between projects inside it with no keypress, never above the total or below a floor. The panel shows the split on `b` and reverts it ([`docs/allocation.md`](docs/allocation.md)). ### Waiting on a condition `run.waitFor` holds a job in the queue, unstarted and unbilled, until a time passes or your own check script says go. The script gets only the job's target id, never a title or a body ([`docs/wait-for.md`](docs/wait-for.md)). ### A budget split a flow can plan Set a dollar total per week and a floor per project, and let a weekly flow move the rest between projects by weight. The worker does the arithmetic, bounds each move and keeps an audit log you can revert from ([`docs/allocation.md`](docs/allocation.md)). The portfolio manager example plans the week and reports what it asked for ([`docs/portfolio-manager.md`](docs/portfolio-manager.md)). Its cron trigger carries a `[portfolio]` badge in the panel. `b` in the panel shows the split, the plan in force with the reasons it gave, and the history, where `r` reverts to an earlier row: ![The panel's budget split view: the weekly envelope and its delegation rules, each project's floor, weight, share and spend with the Valkey key it counts under, the headroom, the applied plan with its reasons, and the history across two hosts with a revert and a refused plan](docs/images/dispatch-allocation.svg?v=2026-10-14) ### More than one machine Several machines can share one queue, one budget and one panel once each worker has a name (`PI_WORKER_NAME`). Each machine keeps its own memory and CPU budget for its jobs, and a job on the shared queue waits for a machine its size fits on ([`docs/sizing.md`](docs/sizing.md)). Do not share the sandbox directory between them ([`docs/multi-host.md`](docs/multi-host.md) says why). `u` in the panel shows each machine: its slots in use, what its budget has promised the jobs it runs, how many jobs wait for its budget, the jobs running there now, and how busy it was over the last 7 days, from the run records. `pi-dispatch capacity`, `pi-dispatch doctor`, the insights page and the `dispatch_capacity` tool read the same report. It counts jobs only: a machine busy with other work reads as idle. ![The panel's hosts view: three machines, each with its slots in use of its limit, the memory and CPU its budget has promised against that budget (or that it has none), its running jobs with project, size and age, a host with an orphaned container, and the last 7 days as busy share, average and peak jobs at once, time at the slot limit, the 95th percentile wait and the projects that used it most, each host's last doctor run with its failing and warning checks and their fixes (one result stale, one host that has published none), ending with the note that only jobs are counted](docs/images/dispatch-hosts.svg?v=2026-10-26) ## Flows: the custom prompt a trigger runs A **flow** is a pi skill, a Markdown instruction file, committed to the target repo at `.pi/skills//SKILL.md`. That file is the prompt. A trigger only names the flow, so each repo can define the same flow name its own way. The [`examples/`](examples/) folder has a small one to start from. A skill ships with its scripts and references, within size limits ([Flows in detail](docs/triggers.md#flows-in-detail)). ```markdown --- 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: ". Do not change behavior. ``` The flow holds the standing instructions. The **task** is the one off request: your `--task`, a trigger's `task`, or the issue or comment text. Flows are read from the **default branch**, so a flow must be merged before a trigger can use it. That merge is the repo's consent. `ai-trigger` only decides **which flows a model may start**: through the `dispatch_run` tool, or by job chaining (a finished job asking for a follow up job). It does not decide who may fire a job. A cron or forge trigger in your reviewed `triggers.json` runs its flow either way, because a human approved that pairing by writing the file. Leaving the line out does **not** stop a label or comment trigger. ### Multi-stage workflows, and third-party pi extensions A skill can call other skills, so the simplest workflow is that chain running inside one job. For more structure, stage pi extensions into the deployment with `pi-dispatch import-pi --with-packages`. They load in every job at an exact pinned version, can be withheld per trigger with `"packages": false`, and never shadow your repo's own skills. One trigger is still one job with one budget, so budget for the whole chain. Where a workflow keeps its state differs between cron and forge jobs: [`docs/workflows.md`](docs/workflows.md) and [`docs/triggers.md`](docs/triggers.md#multi-stage-workflows-and-third-party-pi-extensions). ## What runs, and what protects you ```mermaid flowchart LR CLI["pi-dispatch run ./folder --task ..."] -->|enqueue| Q[("Valkey + BullMQ
the wait-list, AOF")] Q --> B{"under the deployment and scope caps,
the project's share of the envelope
and the turn budget?"} B -->|no| STOP["refused before any spend"] B -->|yes| C["one ephemeral container, removed after the job
all capabilities dropped, non-root, no-new-privileges
/job read-only, /workspace = your folder"] C --> PI["pi + Playwright + git + gh
guardrails + your .pi/"] PI -->|"edits in place"| F[("your folder")] ``` Every trigger takes this path: spend is checked before the container starts, and nothing is dropped. Read [`SECURITY.md`](SECURITY.md) before you rely on it. It says plainly what is and is not defended. - **Network egress is denied by default.** Each job gets its own network behind an allowlist proxy. A job the policy cannot serve is refused before it spends. The allowed hosts are yours to list: pi-dispatch cannot know what your flows need to reach, so read [`docs/egress.md`](docs/egress.md) first. - **Token and cost records cover the whole job**, including subagent sessions and `pi` child processes ([`docs/costs.md`](docs/costs.md#pi-child-processes)). The per job token budget is enforced against that same total. - **The container runtime is a named backend** that declares what it guarantees. [`docs/backends.md`](docs/backends.md) is the contract. `pi-dispatch doctor` prints the declaration. `pi-dispatch doctor --live` reads eight of those properties back from short lived real containers on your host. [`docs/podman.md`](docs/podman.md) says what each Podman setup gets and which ones are refused. ### What is inside the container while a job runs Two credentials go in as environment values. **The agent can read both.** The runner starts the agent session in its own process, so `process.env` is the agent's environment. The guardrails tell the agent never to print or send them, but that is prompt text, not enforcement. - **The provider key**, under your provider's variable name. It cannot be scoped, because the agent needs it to work. Bound it with a **spend limit at your provider** instead. Set one. A host that holds both `ANTHROPIC_OAUTH_TOKEN` (or `ANTHROPIC_AUTH_TOKEN`) and `ANTHROPIC_API_KEY` sends both. - **The forge token**, bounded by the auth source you chose. On GitHub that is a one hour token for one repo with the App, or **your whole login, full scope and never expiring**, with the default `gh` source (`GITHUB_AUTH_SOURCE`). On the other forges it is the token you made, for as long as it is valid. - **Anything you list in `PI_FORWARD_ENV`**, the only channel for extra variables (a custom provider's key, say). Same exposure. The limit is on the damage, not on the leak. [`SECURITY.md`](SECURITY.md) states all of it, including the case this design does not defend. None of these have to sit in a file: the worker reads its environment and parses no `.env` itself, so a secrets manager fits in directly ([`docs/secrets.md`](docs/secrets.md)). ## Reuse your existing pi setup Give every job your host pi setup (custom models, global skills, a persona), layered under each repo's own `.pi/`. The repo wins on any conflict: ```bash pi-dispatch import-pi # stage a credential-free copy of ~/.pi/agent into ./pi-global # then set PI_GLOBAL_PI_DIR in .env; doctor checks it holds no credential ``` - **Credentials never enter this copy.** `import-pi` refuses a `models.json` with a key written in it and never copies `auth.json` ([`docs/global-pi-overlay.md`](docs/global-pi-overlay.md)). - **Third party pi packages are pinned once** at the version your host has (always `--ignore-scripts`) and can be declined per trigger ([`docs/workflows.md`](docs/workflows.md)). - **What a repo declares is never installed.** A repo's own `.pi/extensions` do run, because the checkout is the default branch and merging is the gate. But nothing installs packages because a repo asks, since that would put third party install code next to a live forge token ([`SECURITY.md`](SECURITY.md)). - **MCP servers are yours, not the repo's.** List them in the overlay's `mcp.json` (`import-pi` stages it from your host, credentials as `${NAME}` references only), and a trigger names the ones its jobs connect with `"mcp"`. A repo's own `.pi/mcp.json` is never read for a job, and every server is checked before anything is spent ([`docs/mcp.md`](docs/mcp.md)). A github or gitlab trigger can also name `"forge"`: that forge's own MCP server, pinned in the job image and run as the token the job already holds. - **Sessions can continue.** `"resume": true` makes follow up jobs continue the session that opened the pull request. It saves the full transcript to disk, which is a real disclosure: read [`docs/sessions.md`](docs/sessions.md) before you turn it on. - **Jobs can ask and wait.** With `"awaitInput": true` beside `"resume": true`, the agent can post a question and end its run; your reply on the issue continues it. Nothing runs or is billed while it waits ([`docs/sessions.md`](docs/sessions.md#parked-jobs-the-agent-asks-then-waits)). - **Jobs show where they are.** Every running job's turn, steps and running tool show in the console and the panel. With `"progress": true` the agent also names the phase of its plan it is in, and with `"statusComment": true` the worker keeps one comment on the issue up to date with the numbers, never the agent's words ([`docs/triggers.md`](docs/triggers.md#optional-run-fields)). - **Lost runs can continue.** With `"recover": true` a run the worker loses part way (a crash, a lost machine, a container that died) continues from its last checkpoint, at most twice, instead of starting over (not for a repository with pi extensions). Checkpoints hold the transcript and the workspace on disk for up to 7 days: read [`docs/durable-runs.md`](docs/durable-runs.md) before you turn it on. - **Running jobs can be steered.** With `"steer": true`, `pi-dispatch steer "..."` sends a running job a message, which the agent reads after its current tool calls (or, with `--later`, once it would otherwise stop). It reaches the agent as data, never as new rules, and costs only the turns it causes ([`docs/steering.md`](docs/steering.md)). - **Runs open in a terminal.** `pi-dispatch chat ` follows a running job and steers it, or answers a parked one, through the console's own sign-in and actions. On the host that keeps the sessions it opens a finished run's stored conversation in your own pi as a fork, with tools off and the store never written ([`docs/chat.md`](docs/chat.md)). ## Run as a service ```bash pi-dispatch service render # show the unit it would install, with the real paths pi-dispatch service install # LaunchAgent (macOS), systemctl --user (Linux), nssm (Windows, a machine service) pi-dispatch service status # which unit exists, where, and whether it runs pi-dispatch service restart --drain # pause, wait for running jobs, restart, resume ``` `install` renders the [`deploy/`](deploy/) templates without sudo (`--system` on Linux prints the sudo commands instead). A unit uses the folder you run `install` from as its working directory and reads that folder's `.env`. `--receiver` installs the receiver's unit instead of the worker's (the receiver package must be installed beside the worker), `--console` installs the operator console's (`pi-dispatch-console serve`, once the admin package is installed and a console token with the operator role exists: [`docs/console.md`](docs/console.md)), and `--env-setup ` wraps the worker in your secrets manager's setup script ([`docs/secrets.md`](docs/secrets.md)). On rootless Podman (`PI_BACKENDS=podman`) it also installs Valkey, the egress proxy and the proxy's network keeper as systemd units (Podman's Quadlet format), and `up` offers the same ([`docs/podman.md`](docs/podman.md)). Run one worker per container daemon. `install` refuses a second worker unit in the other scope, because each worker's startup cleanup stops every job container it did not start, including the other worker's live jobs. On Windows, `service render` prints the nssm commands it would run, so you can check them first: ![pi-dispatch service render on Windows: the nssm install sequence for the worker, with the real node and package paths](docs/images/cli-service-windows.svg?v=2.0.0) Two limits. On macOS and Windows the service runs only while you are logged in, because Docker Desktop does too. A policy refusal (exit 2) never restarts the service, on any OS, so no supervisor keeps retrying against a paid provider. Steer the running worker from any terminal: - `pi-dispatch pause` stops taking new jobs. It is stored in the queue, so it survives restarts. Jobs still queue up and wait. `pi-dispatch resume` takes jobs again, and `pi-dispatch status` prints counts. - `pi-dispatch cancel ` stops one job in any state. A queued or held job is removed. A running job is stopped on whichever host runs it, and its run record says `operator-cancel`. If no reachable worker owns the job (its host is down, or its worker is older than this command), the command says so and changes nothing. Like `pause`, it reads only `VALKEY_URL`, so a broken forge setting cannot stand between you and the stop ([`docs/wait-for.md`](docs/wait-for.md) covers held jobs and retries). - `pi-dispatch run --trigger ` runs one cron trigger from the triggers file now, once, as its schedule would ([`docs/triggers.md`](docs/triggers.md#firing-a-cron-trigger-by-hand)). - `pi-dispatch capacity` says how busy each host was over the last day, week or thirty days: busy and idle time, jobs at once, memory and CPU promised and used, waits and projects, read from the run records. It never loads the full config: it reads only `VALKEY_URL` (or `--valkey-url`), `PI_LOGS_DIR`, `PI_LOG_RETENTION_DAYS` and `PI_WORKER_NAME`, from the shell or the `.env` of the folder you run it in (the logs directory defaults to `~/.pi-dispatch/logs`, the worker name to this machine's hostname as the worker names itself: lowercased, with any other character a dash), so a broken forge setting cannot stop it. It counts jobs only, so a machine busy with other work reads as idle ([`docs/insights.md`](docs/insights.md#pi-dispatch-capacity)). ## The admin panel The dashboard at the top of this page is a **pi extension**. It loads into your own pi session: no daemon, no web app, no network port. Install it with `pi install npm:@edgehero/pi-dispatch-admin` and type `/dispatch`. If no deployment exists yet, `/dispatch setup` builds one (see the [Quickstart](#from-pi-the-default-route)). It writes a small pointer file (`~/.pi/agent/pi-dispatch-deployment.json`, paths only, never credentials) so the panel finds the deployment from any directory, and your own env vars always win over it. When the deployed version falls behind the console's, a notice points you back at `/dispatch setup`. In the panel: | Key | Does | |---|---| | `↑↓` `Enter` | open a trigger or a run | | `a` | add a trigger (checked, saved atomically, reloaded live) | | `Enter` then `e` `x` | change a trigger's flow, delete it (asks first) | | `p` `r` | pause and resume the queue | | `s` | set a limit | | `w` `m` | manage quiet hours, manage scoped limits | | `j` | show the projects, their members and spend; `Enter` on one filters the runs list | | `b` | show the budget split (the list's `b`; in an opened run, `b` reopens its workspace) | | `u` | show each host: its slots, budget and running jobs now, and how busy it was over the last 7 days | | `h` `f` | show held jobs, show failed jobs | | `l` `o` | open the running job's live log, change the runs sort | | `x` on a running job | cancel it (asks first) | | `i` | show the address of the console's insights page | `Enter` on a run opens its full record. In that view, `b` reopens the run's workspace, which is not the list's `b`: ![The run detail view of a parked triage run: awaiting input, with the reason's sentence and next step, its issue and flow, timing, host and venue, its project, the dollars it settled, turns, tokens and cost, the chain line, and the retained sandbox with its egress setting](docs/images/dispatch-run-detail.svg?v=2026-10-22-710) The same data is there as plain commands, all local, with no model involved: `/dispatch status | runs | logs | budget | triggers | insights | run | pause | resume | set | unset | settings | priorities | setup | secrets`. ![Transcript of /dispatch status, budget, runs, triggers and priorities: queue counts and budget, the scoped limits with their dollar rows, the run history table with tokens, cost, chain and replica per job and a run awaiting input, the triggers list with a portfolio trigger and a trigger with an MCP server, and the budget split with its history](docs/images/dispatch-commands.svg?v=2026-10-22-710) The insights page also draws the trigger and flow topology ([`docs/graph.md`](docs/graph.md)): ![The topology pane of the insights page: cron and forge triggers wired to their flows across the four skill tiers, a command trigger, observed chain edges, loops, an orphan skill, and the legend with the chain caps](docs/images/graph-view.png?v=2.0.0) ### Operating pi-dispatch from your AI The package also ships the `operate-pi-dispatch` **skill**, so you can ask your assistant in plain words: "raise the daily cap to 30", "cap acme/web at ten runs a day", "add a nightly tidy trigger for /srv/site". Reading needs no confirmation. Every config change asks **you** to confirm in a prompt the model cannot answer, and is refused 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` queues a paid run without asking. Six separate limits bound it instead: a folder allowlist, the committed `ai-trigger: allow` opt in, the dirty tree refusal, no spend settings, a rate limit and the daily cap. Raw job logs show in the panel only and never reach the model. ## GitHub automation Label an issue, and a container works on a fresh clone, opens a PR and comments back. - **Only a collaborator's label, `@pi` comment or formal review starts a job.** The label is the approval. A PR from a stranger's fork never starts a job on its own. A review is gated on the **reviewer**, so a collaborator reviewing that fork PR does start one. - **The token can merge.** GitHub puts push and merge behind the same permission, so under every auth source the job's token could merge. pi-dispatch never does, and **branch protection on your default branch is the real control**. The worker refuses a repo without it, before any spend. - **Which token a job holds depends on the auth source.** The GitHub App mints one per job, for one repo, valid for an hour. The default `gh` source forwards **your own login, full scope and never expiring**, into every job that carries a token, and `pi-dispatch doctor` warns about that. A fine grained PAT has the expiry you set. - **The checkout is always the base repo at its default branch**, never a PR branch. A commit that lands on your default branch can run code in a job. Issue and comment text never can; it stays data. `pi-dispatch setup github` creates a GitHub App for you in one browser click, shows every `.env` line before you agree, and never prints a secret. Then pick one of **three ways to run the trigger edge** (the part that takes in forge events): the webhook receiver on the host, the receiver in a container, or the receiver's `poll` mode, which needs no public URL at all. How each works: [`docs/github.md`](docs/github.md). ## Other forges The same machinery, with a setup doc per forge. These forges have one auth source each, a token you create, so `GITLAB_AUTH_SOURCE`, `FORGEJO_AUTH_SOURCE` and `AZURE_AUTH_SOURCE` accept only `pat`. Once that forge's token is set, setting one to `app` gets a clear refusal at boot rather than being quietly ignored. - **GitLab** ([`docs/gitlab.md`](docs/gitlab.md)): webhook at `/gitlab`. You need a project token with `api` scope. Every trigger checks that the person who fired it is Developer or higher. Needs GitLab 17.4 or newer for dedup that survives retries. Self hosted works through `GITLAB_URL`. - **Forgejo and Gitea** ([`docs/forgejo.md`](docs/forgejo.md)): webhook at `/forgejo`. Every trigger checks that the person has permission on that repository. Forgejo only lets a repository admin read someone else's permission, so that check gets its own read only token from an admin account (`FORGEJO_PERMISSION_TOKEN`), which no job ever sees; the harness account itself stays a write collaborator. - **Azure DevOps** ([`docs/azure-devops.md`](docs/azure-devops.md)): a Service Hook at `/azure`. It is the weakest transport of the four (no HMAC signature, no signed timestamp), and the doc explains what that means. It needs a dedicated identity, `run.repository` on triggers, and its own job image (`image/Dockerfile.azure`). ## Run history, costs, and re-opening a run Every job writes a durable record with ids only, never issue or comment text. Raw logs are opt in (`PI_CAPTURE_JOB_LOGS=1`) and stay on the host. Records and panel settings live in `~/.pi-dispatch` by default, outside any repo and outside the temp folder ([`docs/backup.md`](docs/backup.md)). Each record holds a usage ledger per model, which the insights page prices. Tell it what your subscriptions cost in `subscriptions.json` and it shows whether they save money. A zero-rate run with no declared plan reads `$0 (unrated)`, never "free" ([`docs/costs.md`](docs/costs.md)). A finished run's workspace is kept for 24 hours by default. You can reopen it: ```bash pi-dispatch sandbox gh-12345 --publish 3000 # a fresh container on that run's workspace, no credentials ``` You get the same image and isolation, but no token, no provider key and no agent. You are the one working. A kept workspace holds the run's clone and its issue text, so read [`docs/sandbox.md`](docs/sandbox.md) before you keep them longer. When a paid forge job fails, the worker comments on its issue or PR. You can set `PI_ON_FAILURE` to run your own hook ([`docs/notifications.md`](docs/notifications.md)). ## How it compares **vs the Claude Code GitHub Action**: for GitHub only automation the [action](https://github.com/anthropics/claude-code-action) handles label triggered issues with a tenth of the setup. pi-dispatch is for running pi on your own hardware with a real queue, a container boundary, and flows against **local folders** as well as repos. **vs hosted routines and `/loop`**: for "run a prompt on a schedule" they are simpler, with nothing to host. pi-dispatch's cron trigger is for recurring work that needs a project's exact toolchain, or the built in Playwright and Chromium that let a flow build a frontend and check how it renders. ## Status Everything on this page is built and running, on all four forges and on Docker and Podman. The design lives in [`specs/`](specs/), and the specs are the source of truth, not a summary of the code. Start with [`specs/constitution.md`](specs/constitution.md) for the rules that never bend, [`specs/design.md`](specs/design.md) for the decisions and what was rejected, and [`specs/interfaces.md`](specs/interfaces.md) for the file and container contracts. Every spec file ends with a revision history, corrections included. Working on this repo with an AI agent? [`AGENTS.md`](AGENTS.md) is the short version of what matters here. ## Contributing PRs welcome. Sign off your commits with `git commit -s`: this project uses the [DCO](https://developercertificate.org/), not a CLA. If you change how the code behaves, the spec changes with it. `specs/` is the source of truth. A PR that breaks a `CONST-*` entry will be asked to justify changing the rule first, not the code. ## License MIT. See [LICENSE](LICENSE). Built on [pi](https://github.com/earendil-works/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`](https://www.npmjs.com/package/@edgehero/pi-dispatch) (worker and CLI), > [`@edgehero/pi-dispatch-receiver`](https://www.npmjs.com/package/@edgehero/pi-dispatch-receiver), and > [`@edgehero/pi-dispatch-admin`](https://www.npmjs.com/package/@edgehero/pi-dispatch-admin) (the > console). The bare name is theirs.