Petri is a self-hosted orchestrator for AI coding agents. It is a state machine over work items with a board on top: every state binds to a model, a prompt, and a gate, and a card only advances when its gate passes.
You describe the pipeline as configuration - planner → implement → review → human - and Petri drives it, showing you what each agent is doing while it does it.
Mocked data - a scripted walkthrough of a seeded board, not a live agent run.
Status: early development. The design is settled and documented below; the implementation is being built in phases. Nothing here is production-ready yet.
This project was not the plan. It is the result of running agent-driven development for real, on self-hosted infrastructure, and hitting the same wall from two directions.
One sentence: add a task, have an AI implement it, review the result before it lands.
Not a chat window. Not a CLI invocation. A durable work item that moves through stages, where different stages can use different models, where a machine checks the work between stages, and where a human sees a pull request at the end rather than a merged commit.
We ran a self-hosted agent task board and drove it with our own dispatcher against a shared coding-agent gateway. It worked - cards became pull requests - but every step toward the pipeline we actually wanted ran into the same wall: the tool's data model did not have the shape.
Concretely, the board exposed:
task.status : inbox | assigned | in_progress | quality_review | done
agent.role : coder | reviewer | tester | devops | researcher | assistant | agent
columns : not configurable - no endpoint to define one
A fixed five-value status enum, a fixed role list with no planner, and no way to add a column. A pipeline whose stages are your stages cannot be expressed. You end up encoding roles in tags or free-form metadata, and the board - the entire reason to have a board - shows none of it.
The deeper mismatch was architectural. Its runtime API described agent CLIs it expected to install and run on its own host:
{"runtimes": [{"id": "...", "installed": false, "running": false, "version": null}]}Everything read installed: false, permanently, because our agents deliberately run somewhere else - in a hardened pod, with the repository credential, isolated from the board. The board wanted to be the agent host. We wanted it to be a control plane. Those are different products, and no amount of glue reconciles them.
That is the honest reason for Petri: not that the other tool was bad, but that it was solving a different problem well.
Before writing anything, we seriously evaluated migrating to OpenHands, which is a strong project and does far more than we needed. Two findings stopped it, and both are worth recording because they generalise:
1. Forge support is not a detail. OpenHands integrates GitHub, GitLab, Bitbucket and Azure DevOps. Gitea/Forgejo support is an unimplemented feature request. Our repositories are on Forgejo, so the automatic issue-to-pull-request resolver - the exact feature we would have migrated for - was the part that would not work. We would have written the forge integration ourselves, which is precisely the glue we were trying to escape.
2. The hard problems are below the framework. Local models served through Ollama have well-documented tool-calling failures: the model reasons about which tool to call and then never calls it, or emits tool-call JSON that does not parse. We had already solved this with a buffering adapter. OpenHands uses the same underlying LLM proxy and hits the same wall - there are open issues for exactly this. Migrating would have meant re-solving a problem we had already solved, not inheriting a fix.
The lesson: an orchestrator switch does not fix model-layer or infrastructure-layer problems. Know which layer your pain is in before you rewrite.
Three production failures shaped this design more than any feature list. All three looked like "the model is too weak" and none of them were.
Silence is not death; elapsed time is not progress. A turn was killed at a flat ten-minute wall-clock timeout while it was actively working. Its longest gaps between events were 278 and 235 seconds - single model calls on a serialised GPU, not stalls. A turn making five such calls legitimately runs past ten minutes. Worse, the timeout was reported to the caller as success, so the failure surfaced two steps later as "nothing was committed" - pointing at entirely the wrong thing.
Design consequence: a run is bounded by silence, not by wall clock. Petri records a run's status and the timestamp of its last observed event, because an agent can be
busyfor five minutes and perfectly healthy - orbusyfor five minutes and hung, and only the last-event time can tell you which.
Context is a resource, and it must be scoped to the unit of work. Our session identifier was stored per checkout rather than per branch, so every new card silently continued the previous card's conversation. By the fourth unrelated task the context was exhausted, and the agent's first step returned in 20 milliseconds with reason: "length" and zero tokens - then burned six minutes achieving nothing. It read exactly like a model that could not do the work.
Design consequence: the unit of work owns its context. In Petri a card's run has its own session, and a state transition is an explicit decision about whether context carries forward.
Blocking execution is unobservable execution. The dispatcher held a single HTTP connection open for the duration of a turn - up to an hour - and learned nothing until it returned. There was no channel to ask "are you alive?", because the only channel was busy carrying the answer.
Design consequence: runs start asynchronously and are polled. The agent gateway already exposes session status, the agent's own todo list, a live diff, an event stream, and an abort endpoint. All of that was invisible because the execution model could not surface it.
It would be easy to read the above and build "a board with more columns". That misses the point. The state machine is the design, for four reasons:
Roles become data, not code. "Model X drains the planner column, its output is checked, then it is handed to model Y" is a configuration row:
| state | model | gate | on pass | on fail |
|---|---|---|---|---|
planner |
a strong reasoning model | plan names files and acceptance criteria | implement |
planner |
implement |
a local coding model | repository gate: secrets, protected paths, tests, rebase | review |
implement |
review |
an independent model | explicit approval verdict | human |
implement |
human |
– | you | done |
implement |
Adding a role is a row. Changing which model plans is a row. Neither is a deployment.
The gates are where correctness lives. The interesting part of agent-driven development is not generating a diff - models do that easily. It is everything between the diff and the merge: does it contain a credential, does it touch protected paths, do the tests pass, does an independent reviewer approve, did a human look. A state machine gives those checks a place. Without one they become if statements scattered through a script, which is exactly what we had, and exactly where our bugs were.
A transition is an audit record. status: in_progress tells you nothing. A transition log tells you which model produced the change, which gate passed it, on which attempt, and how long it took. When an agent does something surprising - and it will - that history is the only way to find out why.
States make failure legible. A card sitting in implement with three failed transitions is a different problem from a card that never left planner. One is a capability problem, the other a specification problem. A single "failed" status cannot distinguish them, and we spent real hours on that confusion.
Petri never runs git, and shares no filesystem with the agent. The agent clones, commits and pushes with its own credential, in a workspace Petri names and never opens. Petri sends a prompt, watches the session, reads what landed from the forge's API, and decides.
We tried the other arrangement first: a bespoke shim between the two, holding the credential and the checkout. It worked, and nobody else could run it - it tied the orchestrator to one agent runtime, on one machine, with one shared disk. Anything that can run a shell, hold a token and answer four HTTP calls can sit behind the interface Petri actually needs.
What Petri does own is the order. The agent commits and reports its diff but does not push. Petri scans that diff for secrets, checks protected paths and branch discipline, asks an independent model for a verdict - and only then asks for the push. Then it reads the branch back from the forge, checks it again because what landed may not be what was reported, and opens the pull request itself. Nothing reaches the remote before the secret scan, because once a branch is pushed a credential is in its history whatever anyone decides afterwards.
Petri never merges. Landing a change is a person's decision.
- Configurable pipeline - states, the model bound to each, prompts, gates and transitions are data, not code
- Board UI - server-rendered, states as columns, no separate frontend build
- Pipeline editor - drag-reorder states, edit gates/models/prompts/routing without a curl command
- New board form - name, slug (auto-suggested from the name, editable), forge and repository, straight into its pipeline editor - no curl command to get started at all
- Instance settings - board settings; live-editable policy (concurrency, timeouts, protected paths); and live-editable connections (gateway, forge, reviewing model) with a one-click reachability check, database-backed except the gateway password, which stays only in the environment
- Live run visibility - per-card session status, the agent's own todo list, live diff, and time since last activity
- Pluggable gates - repository gate, independent model verdict, plan-shape validation, human approval
- Full transition history - who moved a card, why, on which attempt
- Multi-forge - Forgejo first, others behind one interface
- REST API with OpenAPI/Swagger UI
- MCP server so assistants can query and drive the board
- Prometheus metrics via Actuator and Micrometer
- H2 by default, PostgreSQL on request - zero-setup locally, real database in production
docker run -p 8080:8080 ghcr.io/wenisch-tech/petri:latestStarts with an embedded H2 database, no configuration required. Open http://localhost:8080.
There is no fixed default password. The board login user is admin. With no
petri.security.password set, Petri generates a random one every time it
starts and logs it as a WARN line - easy to miss among the Spring Boot
startup noise. Search the log for Generated one for this run only:
WARN ... SecurityConfig : No petri.security.password set. Generated one for this run only: e525add8-1011-4da2-a444-71fb91fbd3b7
That value is only good until the process restarts - a new container, a redeploy, or a plain restart all generate a new one. Set the property yourself for a password that stays put:
docker run -p 8080:8080 -e PETRI_SECURITY_PASSWORD=... ghcr.io/wenisch-tech/petri:latestThe write API is stricter still: with no petri.security.api-key set it
rejects every request rather than opening up, since it is what queues agent
runs against real repositories. See
Access and the API for both.
helm install petri oci://ghcr.io/wenisch-tech/charts/petriSee charts/petri/values.yaml for the full value reference.
Petri runs on H2 by default so it starts with no setup. Point it at PostgreSQL with standard Spring properties, as environment variables or program arguments:
docker run -p 8080:8080 \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/petri \
-e SPRING_DATASOURCE_USERNAME=petri \
-e SPRING_DATASOURCE_PASSWORD=... \
ghcr.io/wenisch-tech/petri:latestFlyway migrations run automatically against either database.
Already have Forgejo and an agent gateway running? See Your First Pipeline to connect them and run something end to end.
Full configuration reference: docs/.
Documentation sources live in docs/ and are built with MkDocs.
- Java 25+
- Maven 3.8+
mvn spring-boot:runmvn verifymvn -B -DskipTests packageLicensed under the GNU Affero General Public License v3.0. See LICENSE.md.
