> ## Documentation Index
> Fetch the complete documentation index at: https://piloop.autoresearch.inc/llms.txt
> Use this file to discover all available pages before exploring further.

# Extension points

> Add a role, a trigger, a runtime, a harness, an MCP server or an evaluate command.

piloop has six extension points. Three need only configuration: a role, a harness and an MCP server. A trigger can be
configuration (a `sql` query) or code in a package. A runtime is code in a package. The evaluate command is your own
program.

## Roles and triggers

A role is a table in `piloop.toml`: what the agent is told (its prompt), when it starts (the run queue, or its
triggers), where it works (its runtime and working copy), how it works (harnesses, models, MCP servers, timeout,
concurrency) and what it may file. The built-in worker, watchdog and research roles are the same tables with defaults.
[Roles](/concepts/roles) describes each key and each built-in trigger.

A role either does runs or sweeps. A role that does runs (`runs = true`, like the worker) takes ideas from the run
queue, works in a new worktree at the base of its run, and its commits are scored and judged. A sweeping role (like
the watchdog and research) starts on its triggers, works at the head or in the project checkout, and nothing it commits
is scored.

### Define a role

Add a table with a new name, a prompt file and at least one trigger. This role reads each new escalation in the inbox
and writes a plan for it, one agent at a time, in the project checkout:

```toml theme={null}
[roles.planner]
prompt = "briefs/planner.md"
triggers = [
  { kind = "sql", when = "SELECT id FROM comments WHERE kind = 'escalation' AND acked_at IS NULL AND created_at > :since" },
]
workdir = "project"
max_concurrent = 1
timeout = 1200
files = "seeds"
writes = ["comments(finding) on the escalation", "seeds via idea seed"]
```

Then write the prompt and store the config:

```bash theme={null}
mkdir -p briefs
printf '# Planner\nFor each new escalation, write a finding with a plan of three steps.\n' > briefs/planner.md
piloop init .
```

The daemon now checks the query on each tick. When it returns a row, the daemon requests a `planner` agent, and
`piloop status` lists it as `planner#<worker id>`. The agent signs its comments `planner#<worker id>`.

### Define a role that does runs

Add a table with a new name and `runs = true`. The role takes the worker's defaults, so every other key is optional.
This role runs the ideas of the `speed` track with Claude, two at a time, and stops each agent after 10 minutes:

```toml theme={null}
model = { pi = "anthropic/claude-opus-5-5", claude = "claude-opus-5-5" }

[roles.sprinter]
runs = true
takes = "track = 'speed'"
prompt = "briefs/sprinter.md"
harnesses = ["claude"]
max_concurrent = 2
timeout = 600

[roles.worker]
takes = "track != 'speed'"
```

`takes` is an SQL condition on the `ideas` table, so it can select by any column: `priority`, `track`, `family`,
`harness`, or a subquery. Without `takes` on the worker, the worker also runs the `speed` ideas that wait for a free
`sprinter` slot. Write the prompt and store the config:

```bash theme={null}
mkdir -p briefs
printf '# Sprinter\nMake the change that the idea names, measure it once, and commit.\n' > briefs/sprinter.md
piloop init .
```

The daemon queues each `speed` idea for a `sprinter` run. The run's agent signs `sprinter#<worker id>`, its brief has
the idea and the starting point as a worker's has, and its commits are scored and judged like a worker's.
[Roles](/concepts/roles#define-a-role-that-does-runs) lists which rules apply to each role and which apply to every run.

### Add a trigger or a role from a package

A Python package can add triggers and roles through two entry point groups:

* `piloop.triggers`: each entry point names a subclass of `piloop.triggers.Trigger`. Its name is the `kind`. Its
  fields are its parameters. Its `fire(tick)` method returns a `Fire` (the reason, and how many agents) or None. `tick`
  gives it the session, the project, the role and the queries that the built-in triggers use (`piloop.triggers.Tick`).
* `piloop.roles`: each entry point names a `piloop.config.SweepRole` or a `piloop.config.WorkerRole` (a role that does
  runs), the defaults of the role. Its name is the name of the role. `piloop init` stores the role in the project's
  config, with the project's `[roles.<name>]` table over the defaults.

piloop loads each group once per process. A name that a built-in trigger or role has already is an error.

```python theme={null}
from datetime import timedelta
from pathlib import Path
from typing import Literal

from sqlalchemy import func, select

from piloop.config import SweepRole
from piloop.db.models import Candidate, Verdict, utcnow
from piloop.triggers import Fire, Tick, Trigger


class NoKeep(Trigger):
    """``no_keep``: no new keep for ``hours`` hours, once per agent of the role."""

    kind: Literal['no_keep'] = 'no_keep'
    hours: float = 6

    def fire(self, tick: Tick) -> Fire | None:
        keeps = select(func.max(Candidate.created_at)).where(
            Candidate.project_id == tick.project.id, Candidate.verdict == Verdict.KEEP
        )
        latest = tick.s.scalar(keeps) or tick.project.created_at
        started = tick.last.started_at if tick.last is not None else latest
        if utcnow() - max(latest, started) < timedelta(hours=self.hours):
            return None
        return Fire(f'no keep for {self.hours:g} hours')


AUDITOR = SweepRole(prompt=str(Path(__file__).parent / 'auditor.md'), triggers=[NoKeep()], timeout=1800)
```

Register both in the package's `pyproject.toml`, and install the package in the environment of `piloop`:

```text theme={null}
[project.entry-points."piloop.triggers"]
no_keep = "my_package:NoKeep"

[project.entry-points."piloop.roles"]
auditor = "my_package:AUDITOR"
```

Any project can then use `{ kind = "no_keep", hours = 12 }` in the triggers of a role, and gets the `auditor` role at
its next `piloop init`.

## Runtimes

A runtime is where an agent process runs. The daemon depends only on the `Runtime` protocol in
`piloop/runtime/__init__.py`:

| Method | What it does |
| - | - |
| `start(spec)` | Starts the agent from a `WorkerSpec` (the argv, the first prompt, the working directory) and returns a `Handle`. |
| `wait(handle, timeout)` | Blocks until the agent ends. After `timeout` seconds, kills it and returns `timed_out`. |
| `status(handle)` | Returns the current state without waiting. |
| `session_id(handle)` | Returns the harness's session id, when the runtime can learn it. |
| `jobs(handle)` | Lists the jobs that the agent started and that still run. |
| `prompt(handle, text)` | Queues a message for the agent's next turn, never into the turn in progress, or raises `NotSteerable`. |
| `read(handle, lines)` | Returns the recent output as plain text. |
| `kill(handle)` | Stops the agent and what it left running. |

A runtime class also has two class attributes, which piloop reads before it constructs the runtime:

* `kind`: the name of the runtime. It is the name of its entry point, the value of `runtime` in `piloop.toml` and the
  value of `workers.runtime`.
* `invocation`: the argument list of a harness that the runtime starts. `headless` is the argv for a process without a
  terminal, with the first prompt in it. `interactive` is the argv for a session in a terminal, and the runtime types
  the first prompt after the start.

The constructor takes the project's name and path. piloop has two runtimes: `subprocess`, the default (a background
process that cannot receive messages), and `herdr` (a terminal pane that you can watch and type into). A project
chooses its runtime with the `runtime` key of `piloop.toml`, and a role can choose another one with its own `runtime`
key. `piloop init` refuses a name that no runtime has.

### Register a runtime

A Python package adds a runtime with an entry point in the `piloop.runtimes` group. This runtime starts each agent as
the `subprocess` runtime does, at a lower CPU priority:

```python theme={null}
from dataclasses import replace
from typing import ClassVar

from piloop.harnesses import Invocation
from piloop.runtime import Handle, WorkerSpec
from piloop.runtime.subprocess import SubprocessRuntime


class NiceRuntime(SubprocessRuntime):
    """``nice``: background processes, like ``subprocess``, at a lower CPU priority."""

    kind: ClassVar[str] = 'nice'
    invocation: ClassVar[Invocation] = Invocation.HEADLESS

    def start(self, spec: WorkerSpec) -> Handle:
        return super().start(replace(spec, executable='nice', args=['-n', '10', spec.executable, *spec.args]))
```

```text theme={null}
[project.entry-points."piloop.runtimes"]
nice = "my_package:NiceRuntime"
```

Install the package in the environment of `piloop`. A project then sets `runtime = "nice"`, or a role sets it in its
own table. A name that a built-in runtime has already, a `kind` that is not the name of the entry point, or a class
without an `invocation` is an error that names the entry point.

`piloop attach`, the Herdr checks of `piloop doctor` and the tab column of `piloop watch` work with the `herdr`
runtime only, because they call Herdr.

## Storage backends

The artifact store keeps its blobs in `$PILOOP_HOME/store` and can mirror them to a remote. `[storage.remote]` in
`$PILOOP_HOME/config.toml` picks the remote's backend by `kind`. `s3` and `gcs` are built in. The backend owns every
other key of that table. The keys of `[storage]` itself, such as `evict_after_days` and `min_free_gb`, are shared by
every backend. The [configuration reference](/reference/configuration) shows the same store with each backend.

### The contract

A remote stores whole objects by key. The store never lists, deletes or changes an object. A remote has:

* `url`: one spelling of where it puts objects, without a trailing `/`. The store records it at its first upload, and
  it refuses to open later with another `url`.
* `upload(key, path)`: store the file at `path` under `key`, streaming it. Uploading a key again is harmless.
* `download(key, path) -> bool`: write the object under `key` to `path`, streaming it. Return `False`, and write no
  file, when there is no such object. Raise on any other failure.
* `has(key) -> bool`: whether an object is stored under `key`.

### Write a backend

A backend is two classes. A settings model subclasses `piloop.store.RemoteConfig`. Its `kind` is a literal, its
other fields are the keys of `[storage.remote]`, and its `open()` returns the remote. This backend mirrors the store
to a directory, for example a network share:

```python theme={null}
import shutil
from pathlib import Path
from typing import Literal

from piloop.store import Remote, RemoteConfig


class ShareConfig(RemoteConfig):
    """``[storage.remote]`` with ``kind = "share"``: a mounted directory."""

    kind: Literal['share'] = 'share'
    root: Path
    """The directory that the objects go under."""

    def open(self) -> Remote:
        return Share(self.root)


class Share:
    def __init__(self, root: Path) -> None:
        self.root = root
        self.url = f'file://{root}'.rstrip('/')

    def upload(self, key: str, path: Path) -> None:
        target = self.root / key
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(path, target)

    def download(self, key: str, path: Path) -> bool:
        if not (self.root / key).is_file():
            return False
        shutil.copyfile(self.root / key, path)
        return True

    def has(self, key: str) -> bool:
        return (self.root / key).is_file()
```

Register the settings model with an entry point in the `piloop.stores` group. The name of the entry point is the
`kind`:

```text theme={null}
[project.entry-points."piloop.stores"]
share = "my_package:ShareConfig"
```

Install the package in the environment of `piloop`, then select the backend in `config.toml`:

```text theme={null}
[storage.remote]
kind = "share"
root = "/mnt/archive/piloop"
```

piloop checks the table against `ShareConfig` when it opens the store: `piloop serve`, `piloop restore` and
`piloop store` fail on a key that the model does not have. A name that a built-in backend has already, or an entry
point that does not name a `RemoteConfig` subclass, is an error that names the entry point.

### Test a backend

`piloop.store.contract.check_remote` runs the contract against a remote: missing keys, an upload and a download, and
a store that mirrors to the remote and keeps nothing local. It raises `ContractBroken` with the rule that failed.
piloop's own tests run it against `s3` and `gcs`. Run it in the tests of your package, against a scratch location:

```python theme={null}
from pathlib import Path

from piloop.store.contract import check_remote

from my_package import ShareConfig


def test_the_share_keeps_the_contract(tmp_path: Path) -> None:
    check_remote(ShareConfig(root=tmp_path / 'share').open(), tmp_path / 'scratch')
```

It writes under `piloop-contract/<random>/`, and the contract has no delete, so point it at a bucket or a folder that
you can empty.

## Harnesses

A harness is a coding agent CLI. A `[harness.<name>]` table in `piloop.toml` says how to start it: the executable, the
argv for an interactive session (`interactive`, for Herdr) and for a background run (`headless`), and the startup
dialogs to accept. piloop fills in placeholders such as `{model}`, `{session_id}`, `{cwd}` and `{prompt}`.

```toml theme={null}
harnesses = ["claude", "mycli"]
model = { claude = "claude-opus-5-5", mycli = "my-model" }

[harness.mycli]
executable = "mycli"
headless = ["--model", "{model}", "{prompt}"]
```

The built-in harnesses `pi`, `claude` and `codex` are definitions of the same kind, so a project can override any key
of them. Two optional parts of a harness have their own registries in the code: the `usage` reader that reads the
tokens from the harness's session files (`piloop/usage/`), and the `mcp` renderer that writes the MCP servers in the
harness's own flags (`piloop/mcp/`). The [configuration reference](/reference/configuration#harness) has every key.

## MCP servers

An `[mcp.<name>]` table defines an MCP server once, and `[roles.<name>] mcp` gives it to the agents of a role. piloop
writes it in the format of each harness, and passes API keys only as environment variables. See
[MCP servers](/guides/mcp-servers).

## The evaluate command

The evaluate command is the contract between piloop and your project. The daemon runs it in a clean worktree of each
commit. The command must:

* print one line `METRIC <name>=<value>` on stdout for each metric in `piloop.toml`;
* exit with status 0, within `evaluate_timeout` seconds. Otherwise the candidate is a `crash`.

When the daemon sets `PILOOP_UNITS_FILE`, the command can also write the result of each unit (each example, record or
file) to that path, so that the verdict can use a paired bootstrap. See
[The evaluate command](/concepts/projects#the-evaluate-command) and [per-unit results](/concepts/projects#per-unit-results).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.