> ## 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.

# Harnesses, runtimes, roles, triggers and sections

> Plugins that start the agents, define what they do and when, and show a campaign.

A harness is the coding agent CLI that an agent runs in. A runtime starts and steers the agents. A role says what an agent does, and its triggers say when it starts. A section
is one part of what `piloop status`, a brief or a UI shows. [Agents and roles](https://piloop.autoresearch.inc/concepts/roles.md) describes them as a
user sees them.

## Harnesses

A harness provider holds what piloop knows about one coding agent CLI. This one is Claude Code's beta build, which every
campaign that does not set `harnesses` rotates through:

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

from piloop.capabilities import Claude


class ClaudeBeta(Claude):
    """``claude_beta``: Claude Code installed as ``claude-beta``."""

    DEFAULT: ClassVar[bool] = True
    executable: str = 'claude-beta'
```

```toml theme={null}
[project.entry-points."piloop.harnesses"]
claude_beta = "my_package:ClaudeBeta"
```

### The contract

A harness provider is a subclass of `Harness`. Its fields are the keys of `[harness.<name>]` with its defaults. Its
methods hold what piloop knows about the CLI: `mcp` writes a role's MCP servers in its flags, `usage` reads the tokens
from its session files when an agent ends, `messages` is what its hook prints for a message, and `skills_dir` is where
it reads user skills.

| Member | What it is |
| - | - |
| `Harness.DEFAULT: ClassVar[bool]` | Whether a campaign that does not set `harnesses` rotates through this one. |
| `Harness.executable: str` | The program to run. |
| `Harness.headless: list[str]` | The arguments for a run without a terminal. Must include `{prompt}`. |
| `Harness.resume: list[str]` | The arguments that continue the session of a run with a new prompt. When the agent's turn ends with messages queued for it (comments, `piloop prompt`), the runtime runs the harness again with these, `{prompt}` being the messages and `{session_id}` the session's id. Must include both. When empty, the agent takes no message while it runs. |
| `Harness.session_id_pattern: str \| None` | A regex with one group that finds the session id in the first lines of the process's stdout. Use it in a harness that cannot take its id as an argument (`{session_id}`). For example, Codex `exec --json` prints `{"type":"thread.started","thread_id":"<uuid>"}` first. |
| `Harness.home: str \| None` | The directory where the agent keeps its config and sessions: a path, which may start with `~` or name a variable as `{env:NAME:default}`. A harness that reads usage finds its session files under it, and the daemon's service unit carries `NAME`. |
| `Harness.session_env: list[str]` | The variables the agent sets for the processes of its session. piloop clears them for every agent, so an agent started from inside such a session is not its child. |
| `Harness.env: dict[EnvName, str \| SecretRef]` | Variables of the agent's environment: a value, or a secret, `{ secret = "NAME" }`. For example a model gateway, `{ ANTHROPIC_BASE_URL = "https://gw.example.com", ANTHROPIC_API_KEY = { secret = "ANTHROPIC_API_KEY" } }`. When unset, the agent uses the login of the machine that runs it. |
| `Harness.mcp() -> McpRenderer \| None` | How it takes the MCP servers of a role. None, by default: it takes none. |
| `Harness.sessions() -> UsageReader \| None` | How to read the session files that its CLI leaves. None, by default: it leaves none that piloop reads. |
| `Harness.usage() -> UsageReader \| None` | How to read the tokens of an agent that ended: its `sessions`, when the session id can be known. |
| `Harness.restore(session_id: str, cwd: Path) -> Path \| None` | Where to write a session file back from the CAS, so that `resume` continues it in `cwd` on any machine. |
| `Harness.messages(lines: list[str]) -> str` | What `piloop agent messages --harness <provider>` prints for the messages it takes: by default, one a line. |
| `Harness.skills_dir(settings: Settings) -> Path` | Where its CLI reads user skills: by default, the Agent Skills directory (`agent_skills_dir`). |
| `Harness.refuses_servers() -> str \| None` | Return why `headless` cannot take MCP servers, or None when it can. |

A table with a new name and `provider = "<harness>"` is that harness over its defaults. A table with a new name and no
`provider` runs its argv and does nothing else: it takes no MCP servers, and records no tokens.
[Harnesses](https://piloop.autoresearch.inc/concepts/roles.md#harnesses) lists the built-in ones.

## Runtimes

piloop starts each agent through a runtime. The runtime starts the agent's process. piloop then waits for the agent,
sends it messages and stops it through the runtime.

### Example: run the agents under `nice`

This runtime starts each agent as the built-in runtime `subprocess` does, but with a lower CPU priority:

```toml pyproject.toml theme={null}
[project.entry-points."piloop.runtimes"]
nice = "piloop_nice:Nice"
```

```python piloop_nice/__init__.py theme={null}
"""``nice``: a piloop runtime that starts each agent as ``subprocess`` does, under ``nice``.

The agents of a long campaign then leave the CPU to your own work first. ``[runtimes.nice] niceness`` in
``$PILOOP_HOME/config.toml`` sets how much lower (default 10).
"""

from dataclasses import replace

from piloop.capabilities import RUNTIMES, AgentSpec, Handle, Subprocess, SubprocessRuntime


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

    niceness: int = 10
    """How much lower than piloop's own priority the agents run."""

    def open(self) -> NiceRuntime:
        return NiceRuntime(RUNTIMES.name_of(self), self.niceness)


class NiceRuntime(SubprocessRuntime):
    """Starts each agent under ``nice -n <niceness>``."""

    def __init__(self, name: str, niceness: int) -> None:
        super().__init__(name)
        self.niceness = niceness

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

Install it, then select it in `piloop.toml` and run `piloop init .`:

```toml theme={null}
runtime = "nice"
```

A role can select its own: `[roles.research] runtime = "nice"`. `[runtimes.nice]` in `config.toml` holds the
runtime's settings.

### The contract

A runtime is a pydantic model, a subclass of `RuntimeConfig`: the name of its entry point is its name, and its fields
are the keys of `[runtimes.<name>]`. Its `open()` returns an object with the methods of the `Runtime` protocol. That
object starts the `headless` argv of a harness, with the first prompt in it. [Runtimes](https://piloop.autoresearch.inc/concepts/roles.md#runtimes)
says what a runtime does for you.

| Method | What it does |
| - | - |
| `start(spec)` | Start the agent from a `AgentSpec` (argv with its first prompt, working directory) and return once it runs. A failure leaves nothing running. |
| `wait(handle, timeout)` | Block until the agent is finished or gone. After `timeout` seconds, kill it and return `timed_out`. |
| `session_id(handle)` | Return the harness's session id once the agent reports it, or None when the runtime cannot learn it. |
| `prompt(handle, text)` | Queue a message for the agent's next turn. It never cuts the turn in progress short. Raise `NotSteerable` when the runtime cannot deliver it (a harness that cannot continue its session, an ended agent). |
| `read(handle, lines)` | Return the recent output as plain text. |
| `kill(handle)` | Stop the agent and what it left running, and release its resources. `piloop.plugins.runtime.kit.tags` finds what the agent left, also in sessions of their own: start the agent with `TAG_ENV` set to a value of its own, and end them with `kill_tagged`. |

### Built in

[Runtimes](https://piloop.autoresearch.inc/concepts/roles.md#runtimes) lists the built-in runtimes.

## Roles

[Agents and roles](https://piloop.autoresearch.inc/concepts/roles.md#define-a-role) defines a role in `piloop.toml`. A plugin can ship one too.

### Example: a role and a trigger from a plugin

This plugin adds a trigger, `no_keep`, that starts an agent when no candidate
was kept for some hours. It also adds a role, `auditor`, that uses this trigger:

```python theme={null}
from datetime import datetime, timedelta
from importlib.resources import files

from sqlalchemy import func, select

from piloop.capabilities import Fire, Outcome, Pass, SweepRole, Trigger, TriggerEntry
from piloop.db.models import Candidate


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

    hours: float = 6

    def fire(self, p: Pass) -> Fire | None:
        return Fire(f'no keep for {self.hours:g} hours') if p.now >= self._due(p) else None

    def due_in(self, p: Pass) -> timedelta | None:
        return max(self._due(p) - p.now, timedelta(0))

    def _due(self, p: Pass) -> datetime:
        keeps = select(func.max(Candidate.created_at)).where(
            Candidate.campaign_id == p.campaign.id, Candidate.verdict == Outcome.KEEP
        )
        latest = p.s.scalar(keeps) or p.campaign.created_at
        started = p.last.started_at if p.last is not None else latest
        return max(latest, started) + timedelta(hours=self.hours)


class Auditor(SweepRole):
    """``auditor``: the role's defaults are its fields' defaults."""

    PROMPT = files(__package__) / 'auditor.md'  # ships in the package
    triggers: list[TriggerEntry] = [NoKeep()]
    timeout: float = 1800
```

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

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

Any campaign can then use `{ provider = "no_keep", hours = 12 }` in the triggers of a role, and gets the `auditor` role,
with its table in `piloop.toml` over the package's defaults, at its next `piloop init`.

### The contract

A role is a table in `piloop.toml`: its prompt, when it starts (the run queue, or its triggers), where it works, how
(harnesses, models, MCP servers, timeout, concurrency) and what it may file. A role that does runs (`runs = true`)
takes ideas from the run queue, and its commits are scored and judged. A sweeping role starts on its triggers, and
nothing it commits is scored. A plugin's role is a subclass of `SweepRole` or of `ImplementerRole` whose fields have
the role's defaults, and whose `PROMPT` is the prompt that starts its brief: a file of its package. `prompt` in the
table overrides it. [Roles](https://piloop.autoresearch.inc/concepts/roles.md) describes each key.

### Built in

The built-in roles are the same tables with defaults, so a campaign can change any key of them:

| Role | Does runs | Triggers | Files | What it is |
| - | - | - | - | - |
| `implementer` | yes | the run queue | `nothing` | A role that does runs. |
| `orchestrator` | no | the run queue | `ideas` | Decides escalations, answers messages, steers the work and records each decision. |
| `research` | no | `low_water`, `seed`, `stalled` | `ideas` | Research develops seeds and keeps ideas ready for the implementers. |
| `scout` | no | `cron` | `seeds` | The scout files outside techniques as seeds for the measured losses. It is off by default. |
| `watchdog` | no | `run_failed`, `dead_at_startup`, `leaking_funnel`, `cron`, `stopped` | `seeds` | The watchdog investigates the anomalies that the daemon detects and reviews progress. |

## Triggers

A `sql` trigger needs no code: its query fires the role when it returns a row, as in
[Define a role](https://piloop.autoresearch.inc/concepts/roles.md#define-a-role). A trigger with code of its own comes from a plugin, as `no_keep` above.

### The contract

A trigger is an entry of `[roles.<name>] triggers`: `{ provider = "<trigger>", <parameter> = <value> }`. A plugin's
trigger is a subclass of `Trigger`: its fields are its parameters. On each pass, for each sweeping role
without a pending request, the daemon calls `fire(p)` of each trigger of the role in order. A pass comes when
something changes in the campaign, or in what a trigger names in `WAKES_ON`, and when the time that a trigger's
`due_in` returns comes. A trigger that compares a time returns when the comparison turns: there is no pass on a timer.
The rows that it reads are the models of `piloop.db.models`, the [data model](https://piloop.autoresearch.inc/reference/data-model.md).

| Member | What it is |
| - | - |
| `Trigger.ON_STOP: ClassVar[bool]` | The daemon checks the trigger once after each stop, when the stopped campaign's last runs are judged, instead of on every pass while it runs. |
| `Trigger.WHILE: ClassVar[frozenset[Quiet]]` | The quiet phases in which the daemon still checks the trigger, and launches what it requests. A trigger that names `stopped` is checked on every pass after the campaign's closing review was requested. |
| `Trigger.WAKES_ON: ClassVar[frozenset[type[EventModel]]]` | The event models that make the daemon check the trigger, beyond those of its campaign's state: its ideas, runs, candidates and agents, its status and config. A trigger that reads anything else, such as the events of a plugin, names them here. |
| `Trigger.INVESTIGATES: ClassVar[bool]` | The agent that the trigger requests gets incidents to investigate. An agent of a role with such a trigger that dies at startup is then no incident: the agent that it would request could die the same way, and so on. |
| `Trigger.fire(p: Pass) -> Fire \| Steer \| None` | Return the request that the trigger makes now, the note that it writes (`Steer`), or None. |
| `Trigger.due_in(p: Pass) -> timedelta \| None` | How long until `fire` can answer otherwise with time alone, or None when only events change its answer. |
| `Pass.backlog(buffer: int, /) -> Backlog` | The campaign's backlog of ideas, with `buffer` ideas beyond one per run slot, as the role's sweeps fill it. |
| `Pass.without_keep(before: datetime \| None, /) -> int` | The finished runs that count since the latest keep, or since the campaign was last set active again. |
| `Pass.live() -> int` | The number of the role's rows that are starting or running. |
| `Pass.killed_streak() -> int` | The number of the role's latest rows, newest first, that were killed in a row. |
| `Pass.cancel(agent_session: AgentSession, /) -> EventModel \| None` | The latest request to cancel the row (`piloop agent cancel`), or None. A cancelled row ends killed. |
| `Pass.empty_sweeps(states: tuple[AgentSessionState, ...]) -> list[AgentSession]` | The role's latest rows in `states`, newest first, until one that defined an idea. |
| `Pass.routine() -> AgentSession \| None` | The role's latest row that got no anomaly. |
| `Fire.reason: str` | The `agent_sessions.reason` of the request. The agent's brief shows it. |
| `Fire.count: int` | The number of rows to request. When several triggers fire together, the largest count wins. |
| `Fire.alone: bool` | The row is for the fires that change what the agent does, such as the incidents that it investigates. |
| `Fire.events: tuple[EventModel, ...]` | The events to emit on the first requested row. |

### Built in

[Triggers](https://piloop.autoresearch.inc/concepts/roles.md#triggers) lists the built-in triggers.

## Sections

### Example: the keeps per hour

```python theme={null}
from sqlalchemy import func, select

from piloop.capabilities import Figure, Figures, Rendered, Rendering, Section, Surface
from piloop.db.models import Candidate


class KeepRate(Section):
    """The keeps per hour since the campaign started."""

    SHOWN_IN = frozenset({Surface.STATUS})
    ORDER = 175  # after the evaluations

    def render(self, r: Rendering) -> Rendered | None:
        mine = (Candidate.campaign_id == r.campaign.id) & (Candidate.verdict == 'keep')
        kept = r.s.scalar(select(func.count()).select_from(Candidate).where(mine)) or 0
        hours = (r.now - r.campaign.created_at).total_seconds() / 3600
        if not kept or hours <= 0:
            return None
        rate = Figure(label='keeps per hour', value=round(kept / hours, 2))
        return Rendered(title='Keep rate', blocks=(Figures(figures=(rate,)),))
```

Declare it with an entry point in `piloop.sections`. `piloop status` then shows it after the evaluations, wherever
`[status] sections` is not set.

### The contract

A section is an entry of a list of sections: `{ provider = "<section>", <parameter> = <value> }`. A plugin's section is
a subclass of `Section`: its fields are its parameters. Its `render` returns a `Rendered`, a title and its blocks. A
block is one of four kinds: `Text`, `Lines`, `Figures` (named values, a number with its unit) and `Table`.
`piloop status`, a brief, the API and a UI render each kind their own way, so a section never formats for one reader.

| Member | What it is |
| - | - |
| `Section.SHOWN_IN: ClassVar[frozenset[Surface]]` | The surfaces whose list shows the section, with its defaults, when the list is not set. Empty: only a list that names it shows it. |
| `Section.ORDER: ClassVar[int]` | Where the section goes in a default list: a lower number comes first. The built-in sections leave gaps, so that a package can put one between two of them. |
| `Section.render(r: Rendering) -> Rendered \| None` | Return the section now, or None when it has nothing to show: then the reader leaves it out. |
| `Rendering.s: Session` | The session to read with. A section writes nothing. |
| `Rendering.now: datetime` | The time of the rendering. A section that shows an age reads this, not the clock. |
| `Rendering.campaign: Campaign` | The campaign, with its config (`campaign.config`). |
| `Rendering.workspace: Path` | The campaign's shared workspace, where agents leave notes and artifacts for each other. |
| `Rendering.cas: Cas` | The home's CAS: the agents' sessions are there (`agent_sessions.session_file`). |
| `Rendering.given: tuple[str, ...]` | The anomaly lines that the agent who reads got to investigate, in order. Empty for every other reader. |

### Built in

[Sections](https://piloop.autoresearch.inc/concepts/roles.md#sections) lists the built-in sections.


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