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

# Configuration

> Every key of piloop.toml, with its type and its default.

`piloop.toml` is at the root of the project repo. `piloop init` reads it again each time you run it, and stores a
copy. The daemon uses that copy, so an edit takes effect only when you run `piloop init`.

To override a key when `piloop init` reads the file, set `PILOOP_PROJECT_<KEY>`. Join nested keys with `__`, for
example `PILOOP_PROJECT_RUNTIME=herdr` or `PILOOP_PROJECT_ROLES__WORKER__MAX_CONCURRENT=2`. The stored copy keeps the
override until the next `piloop init`. The variable has no effect on `piloop serve`.

A type in the tables links to the section that describes it. A table that you leave out has the defaults of its
section.

## Top-level keys

| Key | Type | Default | Description |
| - | - | - | - |
| `name` | string | required | A short name for the project. piloop names its git refs and Herdr workspaces after it. |
| `metrics` | list\[[Metric](#metric)] | required | Every metric the evaluate command prints, each with its `role`. Each track has one `primary`, so a project with one track has exactly one. |
| `tracks` | dict\[string, [TrackConfig](#trackconfig)] | `{}` | `[tracks.<name>]`: the share and addendum of each track. Needed only with several primaries. |
| `confidence` | number | `0.95` | The confidence level of the paired bootstrap behind the verdict. For a `keep`, the lower bound of the primary's gain must be above zero at this level. A guardrail's margin is tested on the bound at this level too. |
| `evaluate` | list\[string] | required | The command (argv) that scores a commit. It runs in a clean worktree of the commit and must print `METRIC <name>=<value>` lines. |
| `runtime` | string | `"subprocess"` | Where agents run. With `subprocess` (the default), every agent is a background process; `piloop watch` shows what it printed. With `herdr` (optional, needs Herdr), every agent gets a terminal pane that you and the orchestrator can read, type into and steer with `piloop prompt`. A package can add a runtime with a `piloop.runtimes` entry point. A role can override it (`[roles.<name>] runtime`). |
| `harnesses` | list\[string] | `["pi"]` | The enabled harnesses. Runs use them in turn, unless an idea pins one. |
| `model` | dict\[string, string] | required | The model id for each harness, for example `{pi = "anthropic/claude-opus-5-5", claude = "claude-opus-5-5", codex = "gpt-6.1-sol"}`. |
| `harness` | dict\[string, [Harness](#harness)] | `{}` | Harness definitions by name: overrides of the built-in harnesses, or new ones. |
| `max_concurrent` | dict\[string, integer] | `{}` | The maximum number of agents of one harness, across all roles of the project, that are started or queued to start. Example: `{claude = 2}` when the provider of its model limits the request rate. When it is the turn of a harness at its limit, the slot goes to the next harness of the rotation. An idea or request that pins that harness waits. When unset, only the limits of the roles apply. |
| `evaluate_timeout` | number | `600` | The number of seconds before the evaluate command is stopped. The evaluation is then a crash. |
| `roles` | [Roles](#roles) | `{}` | The settings of each role (`[roles.worker]`, `[roles.watchdog]`, `[roles.research]`), and the roles that you define (`[roles.<name>]`). |
| `link` | list\[string] | `[]` | Repo-relative paths, ignored by git, that piloop symlinks into every worktree, for example data or virtual environments. |
| `mcp` | dict\[string, [McpServer](#mcpserver)] | `{}` | `[mcp.<name>]`: the MCP servers that a role can list in `[roles.<name>] mcp`. |
| `stop` | [Stop](#stop) | `{}` | `[stop]`: conditions that end the campaign. None by default. |
| `spend` | [Spend](#spend) \| null | `null` | `[spend]`: a hard budget on what the campaign spends outside piloop. None by default. |
| `memory` | [Memory](#memory) | `{}` | `[memory]`: documents to search and the lessons overview in briefs. |

## Metric

One metric. The evaluate command prints it as a `METRIC name=value` line.

| Key | Type | Default | Description |
| - | - | - | - |
| `name` | string | required | The name of the metric, as the evaluate command prints it. |
| `direction` | `higher` \| `lower` | required | `higher` when a larger value is better, `lower` when a smaller value is better. |
| `role` | `primary` \| `guardrail` \| `watch` | `"watch"` | How the metric affects the verdict. A `primary` metric must beat the bar with confidence for a `keep`, and each track has exactly one. A `guardrail` may get worse by at most its `margin`, or the candidate is at most `interesting`. It must never pass its `floor`, or the candidate is a `discard`. A `watch` metric is recorded and never affects the verdict. |
| `track` | string \| null | `null` | Primary metrics only. The name of the track (objective) that this metric is the primary of. When unset, the track has the metric's name, so a project with one primary needs nothing here. With several primaries, each one names its own track, and each track needs a `[tracks.<name>]` table. |
| `margin` | number \| null | `null` | The largest regression from the bar that a candidate may show, in the metric's own units (an absolute value, not a fraction). Required on a guardrail. Also required on every primary of a project with several tracks: when a candidate of another track is judged, that primary is a guardrail. When the evaluate command writes per-unit results, the margin is tested on the confidence bound of the bootstrap. Otherwise it is tested on the aggregate. |
| `floor` | number \| null | `null` | Optional. The worst value a candidate may have: at least this value for `higher`, at most this value for `lower`. It is a hard limit on the aggregate, independent of the bar, and only an edit of `piloop.toml` changes it. Allowed on a guardrail, and on a primary of a project with several tracks, where it applies to the candidates of the other tracks. |

## TrackConfig

`[tracks.<name>]`: one track of the project. A track is an objective with its own primary metric and brief.

A track has the name of its primary's `track`, or else the name of the primary. All tracks build on one trunk. A
run starts from the head, which is the latest keep on the trunk. A keep on any track must also hold the primary of
every other track within that primary's `margin` and `floor`. A project with one primary has one track and
needs no table. A project with several primaries needs one table for each track, and the shares must sum to 1.

Experimental: projects with more than one track.

| Key | Type | Default | Description |
| - | - | - | - |
| `share` | number | `1` | The fraction of the `max_concurrent` of each role that does runs that the ideas of this track get. A free slot of a role goes to the track that is furthest below its share and has an idea that can run, and then to the most urgent idea of that track. A track with no idea that can run gives its slot to another track. A share of 0 stops the track: its ideas wait. |
| `addendum` | string \| null | `null` | A repo-relative file that is appended to the brief of this track's runs, after `[roles.worker] addendum`. Use it to say how work on this objective is done, for example "profile first" or "keep the output identical". |

## Harness

`[harness.<name>]`: how to launch one coding agent.

piloop substitutes these placeholders in both argument lists: `{model}`, `{session_id}`, `{cwd}` (the
agent's working directory), `{log_dir}` (the directory of the agent's brief and logs) and `{prompt}` (the first
prompt, which names the file that holds the brief). An element that is exactly `{mcp}` expands to the role's MCP
servers, in the form that `mcp` names. The three built-in harnesses run without permission prompts: workers run
in worktrees that the daemon owns and throws away, and a sandbox that cannot reach the repo's `.git` cannot
commit. The built-in Claude harness passes `--settings` that move a foreground Bash command to the background
after 5 minutes.

When the argument list in use contains `{session_id}`, piloop creates the session id. Otherwise the runtime
reports it: Herdr detects it for every harness it knows, and the subprocess runtime needs `session_id_pattern`.
When the agent ends, `entire session attach` takes this id, and `usage` uses it to find the session file and
read the tokens the agent spent.

| Key | Type | Default | Description |
| - | - | - | - |
| `executable` | string | required | The program to run. Herdr also uses it as `--kind` to detect the agent. |
| `interactive` | list\[string] | `[]` | The arguments for an interactive session, for a runtime whose `invocation` is `interactive` (Herdr). The runtime types the first prompt, which names the file that holds the brief, after the agent starts. |
| `headless` | list\[string] | required | The arguments for a run without a terminal, for a runtime whose `invocation` is `headless` (subprocess). Must include `{prompt}`. |
| `resume` | list\[string] | `[]` | The arguments that continue the session of a run without a terminal with a new prompt, for a `headless` runtime (subprocess). 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. |
| `startup_dialogs` | list\[tuple\[string, list\[string]]] | `[]` | The dialogs the agent shows when it first starts in a new directory, in the order they appear. Each entry is the text to look for on the screen and the keys that accept the dialog. |
| `entire_agent` | string \| null | `null` | Entire's name for this agent: `pi`, `claude-code` or `codex` (`entire agent list` shows them). When it is set and `entire` is installed, the agent's session is attached to Entire as a checkpoint when the agent ends. When unset, the session is not captured. |
| `session_id_pattern` | string \| null | `null` | A regex with one group that finds the session id in the first lines of the process's stdout. Use it under the subprocess runtime for 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. |
| `usage` | [UsageSpec](#usagespec) \| null | `null` | `[harness.<name>.usage]`: how to read the tokens an agent spent, when it ends, into `workers.usage`. When unset, token usage is not recorded. |
| `mcp` | string \| null | `null` | The renderer that expands the `{mcp}` element of the argv into the role's `[mcp.<name>]` servers: `claude`, `codex` or `pi`. Each renderer also keeps the user's own servers out, where its harness allows it. When unset, the harness takes no servers, and a role that lists servers cannot use it. |

## UsageSpec

`[harness.<name>.usage]`: how to read the tokens that an agent of this harness spent, after it ended.

`reader` names the code that reads them, and the other keys are its parameters. The built-in harnesses set
their specs as data. A project can override any key, or give a custom harness a complete spec. piloop checks the
names when the config loads.

| Key | Type | Default | Description |
| - | - | - | - |
| `reader` | string | `"jsonl"` | The reader to use. `jsonl` reads a JSONL session file, which the keys below describe. It is the only built-in reader. |
| `file` | string \| null | `null` | For `jsonl`: a glob of the session file. piloop substitutes `{home}`, `{claude_config_dir}` (Claude Code's `CLAUDE_CONFIG_DIR`, by default `~/.claude`), `{codex_home}` (Codex's `CODEX_HOME`, by default `~/.codex`), `{cwd}` (the agent's cwd, encoded as `cwd` says) and `{session_id}`. Example: `{home}/.pi/agent/sessions/{cwd}/*_{session_id}.jsonl`. When the glob matches nothing, piloop tries again with `*` for `{cwd}`. This searches one level of folders, not deeper, and the session id makes the match unique. When several files match, piloop takes the newest one. |
| `cwd` | string | `"none"` | For `jsonl`: how the harness names the folder of the agent's cwd. `dash_separators_wrapped` turns `/home/me/repo` into `--home-me-repo--`. `dash_non_alnum` turns it into `-home-me-repo`. `none` means that the folders are not named after the cwd, and matches any folder (`*`). |
| `tally` | string \| null | `null` | For `jsonl`: the file format, which says which records have token counts, how they add up, and which records hold the agent's own text. One of `pi`, `claude` or `codex`. |

## Roles

One table for each role. A built-in role without a table uses its defaults.

A table with a new name defines a new role: a role that does runs (`WorkerRole`) with `runs = true`, else a
sweeping role (`SweepRole`). A role that a package adds with a `piloop.roles` entry point is in the config that
`piloop init` stores, with its table over its defaults.

| Key | Type | Default | Description |
| - | - | - | - |
| `worker` | [WorkerRole](#workerrole) | `{}` | `[roles.worker]`. |
| `watchdog` | [WatchdogRole](#watchdogrole) | `{}` | `[roles.watchdog]`. |
| `research` | [ResearchRole](#researchrole) | `{}` | `[roles.research]`. |

## RoleConfig

The keys that every `[roles.<name>]` table accepts. Each role's table below adds keys and sets defaults.

| Key | Type | Default | Description |
| - | - | - | - |
| `prompt` | string \| null | `null` | The file that starts the brief of this role: a path relative to the repo root, or an absolute path. When unset, a built-in role uses its built-in prompt. A new role needs it. |
| `runtime` | string \| null | `null` | Where the agents of this role run. When unset, the role uses the project's `runtime`. |
| `harnesses` | list\[string] \| null | `null` | The harness rotation of this role. When unset, the role uses the project's `harnesses`. |
| `model` | dict\[string, string] \| null | `null` | The model for each harness in this role. It is merged over the project's `model`. |
| `timeout` | number | `600` | The number of seconds one agent may run before it is stopped. |
| `max_concurrent` | integer | `1` | The maximum number of agents of this role that run at the same time. More requests stay `requested` until a slot is free. |
| `addendum` | string \| null | `null` | A repo-relative file that is appended to `brief.md` in the briefs of this role only. Use it for instructions that only this role needs, for example how the research agent searches. |
| `mcp` | list\[string] | `[]` | The MCP servers that the agents of this role get, by the name of their `[mcp.<name>]` table. Every harness in the role's rotation must be able to take them (`[harness.<name>] mcp`). The built-in `claude`, `codex` and `pi` harnesses can. |
| `writes` | list\[string] | `[]` | What the agents of this role may write, as their brief states it. Only `files` is enforced. |
| `files` | `nothing` \| `seeds` \| `ideas` | `"seeds"` | What the agents of this role may file with `piloop idea`: `nothing`, `seeds` or `ideas`. The CLI refuses the rest. |
| `wake_budget` | integer \| null | `null` | The size in tokens of the lessons overview that opens this role's briefs. When unset, `[memory] wake_budget`. 0 turns it off for this role. |

## WorkerRole

`[roles.worker]`, and every `[roles.<name>]` table with `runs = true`: a role that does runs.

The run queue starts its agents. Each agent does one idea in a new worktree at its run's base, and its commits are
scored and judged when it ends. A new role of this kind takes the worker's defaults, its prompt included.

| Key | Type | Default | Description |
| - | - | - | - |
| `timeout` | number | `1800` | The number of seconds one worker may run before it is stopped. |
| `max_concurrent` | integer | `4` | The maximum number of workers that run at the same time. Each worker is one harness process on this machine. A run that is being scored after its worker ended does not count. |
| `writes` | list\[string] | `["commits", "comments(finding) on your idea", "comments(note) on your run"]` | What a worker may write, as its brief states it. |
| `files` | `nothing` \| `seeds` \| `ideas` | `"nothing"` | A worker files no ideas and no seeds. Its hunches go in its finding, which research reads. |
| `runs` | boolean | `true` | The agents of this role do runs. A table without `runs = true` is a sweeping role. |
| `takes` | string \| null | `null` | The ideas that this role takes: an SQL condition on the `ideas` table, without parameters, for example `priority >= 3` or `track = 'speed'`. When unset, the role takes every runnable idea. A free slot of a role that you define is filled before a slot of the worker, so the worker takes what the other roles leave. |
| `recovery_requeues` | integer | `2` | How many times one idea goes back to the queue from the work its worker left uncommitted, when the run did not succeed (`recovered`): a new worker then starts from that work to finish it. After that, the idea settles as after any failed run, so an idea whose workers keep dying is not run forever. 0 never requeues: the recovered candidate waits for you or the orchestrator. |
| `explore_every` | integer | `4` | At least one run of this role in this many starts from an alternative starting point. When none of the role's last `explore_every - 1` runs did, the next slot goes to the most urgent ready idea that is pinned to a base other than the head (a reference, an older keep or a note), whatever its priority. Nothing changes while no such idea is ready. 0 turns this off. |

## SweepRole

`[roles.<name>]` of a sweeping role: the watchdog, research, or a new role that you define.

A sweep is an agent without a run. Its triggers request it, and nothing is scored when it ends. A table with a new
name defines a new role, and needs `prompt` and `triggers`.

| Key | Type | Default | Description |
| - | - | - | - |
| `runs` | boolean | `false` | The agents of this role do no runs. Set `runs = true` in a new table to define a role that does runs. |
| `triggers` | list\[[Trigger](#trigger)] | required | When the daemon requests an agent of this role: a list of `{ kind = "<trigger>", ... }` entries, checked in order on each tick. The built-in triggers are `anomaly`, `cron`, `seed`, `low_water` (research only), `scout` (research only) and `sql`. An empty list means that agents start only on request (`piloop launch`). |
| `workdir` | `head` \| `project` | `"head"` | Where the agent works: `head`, a detached worktree at the head, or `project`, the project checkout. |
| `interval` | number | `0` | The number of seconds between two routine sweeps of the `cron` trigger, counted from the start of the last one. A sweep that investigates an anomaly is not a routine sweep. 0 turns the timer off. |

## WatchdogRole

`[roles.watchdog]`: the watchdog investigates the anomalies that the daemon detects and reviews progress.

| Key | Type | Default | Description |
| - | - | - | - |
| `writes` | list\[string] | `["comments(escalation, finding, note)", "lessons on the project once it stopped", "seeds via idea seed"]` | What a watchdog may write, as its brief states it. |
| `wake_budget` | integer \| null | `0` | The size in tokens of the lessons overview that opens a watchdog's brief. Off by default: the closing review reads the lessons itself before it writes its own. |
| `triggers` | list\[[Trigger](#trigger)] | `[{"kind": "anomaly"}, {"kind": "cron"}]` | An anomaly requests an incident sweep, and the timer requests a progress review. |
| `interval` | number | `1800` | The number of seconds between two progress reviews, counted from the last review. A sweep that investigates an anomaly is not a review. 0 turns the reviews off. |

## Trigger

One entry of `[roles.<name>] triggers`: `{ kind = "<trigger>", <parameter> = <value> }`.

The daemon checks the entries in the order of the list.

| Key | Type | Default | Description |
| - | - | - | - |
| `kind` | string | required | The name of the trigger: `anomaly`, `cron`, `seed`, `low_water`, `scout`, `sql`, or one that a package adds. |

## ResearchRole

`[roles.research]`: research develops seeds, keeps ideas ready for the workers and scouts for techniques.

| Key | Type | Default | Description |
| - | - | - | - |
| `timeout` | number | `900` | The number of seconds one sweep may run before it is stopped. |
| `max_concurrent` | integer | `1` | The maximum number of sweeps that run at the same time, each on the next harness of the rotation. The default is 1: sweeps that run at the same time read the same evidence and often duplicate each other, and two of them can race to develop the same seed. |
| `writes` | list\[string] | `["seeds via idea develop, or their status to rejected with a note", "ideas via idea add", "comments(finding, escalation) on the project", "comments(note) on an idea"]` | What a research sweep may write, as its brief states it. |
| `files` | `nothing` \| `seeds` \| `ideas` | `"ideas"` | Research develops seeds into ideas and adds ideas of its own. |
| `triggers` | list\[[Trigger](#trigger)] | `[{"kind": "low_water"}, {"kind": "seed"}, {"kind": "scout"}]` | A short backlog, a new seed, and the scout's timer when `[roles.research.scout]` is there. |
| `max_ideas` | integer | `1` | The maximum number of new ideas one sweep may insert. The seeds it develops do not count. `low_water` requests enough sweeps to fill the backlog at this many ideas each. 0 turns `low_water` off: research then only develops seeds. |
| `context` | list\[string] | `[]` | Repo-relative files or globs (for example `docs/error-analysis-*.md`) that hold the project's analysis: error analyses, comparisons with a reference, evaluation reports. The research pack lists each one with its outline, newest first, so that research reads them before it proposes ideas. |
| `buffer` | integer | `2` | The number of ready ideas that `low_water` keeps in addition to one for each worker slot (`[roles.worker] max_concurrent`). With this backlog, a free slot finds an idea at once and does not wait for a sweep. A larger value makes workers wait less for research, but the ideas they run are older. |
| `scout` | [Scout](#scout) \| null | `null` | `[roles.research.scout]`: sweeps that search for outside techniques on a schedule. Off by default. |

## Scout

`[roles.research.scout]`: the scout mode of the research role. It is off when the table is not there.

Every `interval` seconds, a research sweep runs in scout mode (`prompts/research_scout.md`). It states the open
problems of the campaign from the evidence, and searches X, GitHub and arXiv for outside techniques that address
them. It files at most `seeds` of them as seeds, signed `scout#<worker id>`, and puts the rest in the technique
library (the `techniques` table). It never files a defined idea. The search tool limits what one sweep spends on
xAI (`scripts/xai_search_mcp.py --max-usd`). Scout sweeps stop with all other launches under `[spend]`.

Experimental: scout mode.

| Key | Type | Default | Description |
| - | - | - | - |
| `interval` | number | required | The number of seconds between two scout sweeps, counted from the start of the last one. The first sweep counts from the registration of the project. |
| `seeds` | integer | `3` | The maximum number of seeds one scout sweep may file. The other techniques it finds go to the library. |

## McpServer

`[mcp.<name>]`: one MCP server, either streamable HTTP (`url`) or stdio (`command`).

Do not write API keys here. Name the environment variables that hold them. piloop passes these variables from the
daemon's environment to the agent, and the harness puts their values into the header or the server's environment.

| Key | Type | Default | Description |
| - | - | - | - |
| `url` | string \| null | `null` | The URL of a streamable HTTP server. SSE is not supported, because Codex and pi reject it. |
| `headers_env` | dict\[string, string] | `{}` | HTTP only. Maps each header name to the environment variable whose value is sent in it, for example `{ "x-api-key" = "EXA_API_KEY" }`. |
| `bearer_token_env` | string \| null | `null` | HTTP only. The environment variable whose value is sent as `Authorization: Bearer <value>`. |
| `command` | list\[string] \| null | `null` | The command (argv) of a stdio server, for example `["python3", "/path/to/server.py"]`. |
| `env` | list\[string] | `[]` | stdio only. The environment variables that the server reads, for example `["XAI_API_KEY"]`. |

## Stop

`[stop]`: conditions that end the campaign. One is enough. Without the table, the campaign has no end.

The daemon checks the conditions on every tick, before it requests anything. When one holds, the project is
`stopped` (`projects.status`, `stopped_reason`). Running workers are told to commit and stop, and are ended
10 minutes later. What they committed is scored. Pending requests and queued runs are dropped
(`campaign stopped`). One last watchdog sweep is requested, and the triggers request nothing more. An agent that
you request with `piloop launch` after the stop still starts.

`piloop init` resumes a stopped project when this table changed.

| Key | Type | Default | Description |
| - | - | - | - |
| `target` | number \| null | `null` | Stop when the primary metric at the head (the latest keep on the trunk) reaches this value, in its direction. Only for a project with one track. |
| `max_runs` | integer \| null | `null` | Stop after this many worker runs. A run counts while it runs, and when it ends with a candidate that is measured or waits for its measurement. A run that stopped at its own first check, with no commit or only `Piloop-Eval: skip` commits, does not count. A run that a daemon stop, a daemon crash or a campaign stop ended does not count either. `[spend]` and `max_tokens` limit what those runs cost. |
| `max_hours` | number \| null | `null` | Stop after this many hours of wall-clock time since the first run of the project started. |
| `max_tokens` | integer \| null | `null` | Stop when all the agents of the project together spent this many tokens (the `total` of `workers.usage`), summed over the agents that ended. |
| `plateau` | integer \| null | `null` | Stop after this many finished runs in a row without a new `keep` candidate. Every finished run counts, also one with no commit or only `Piloop-Eval: skip` commits: such runs make no progress either. A run that a daemon stop, a daemon crash or a campaign stop ended does not count. |

## Spend

`[spend]`: a hard budget in USD on what the campaign costs outside piloop, for example GPUs or APIs.

Every `interval` seconds, the daemon runs `command` to read the spend from the project (`projects.spend_usd`,
`spend_error`, `spend_at`). When the spend passes a `notices` threshold, the daemon posts one note to the
inbox at priority 1. At `limit`, the daemon launches no agent and starts no evaluation (no request, no
re-measure), and posts one escalation at priority 0. Work that already runs finishes and is scored. A command that
fails stops launches in the same way, with one escalation for each series of failures: an unknown spend is never
read as \$0. To resume, raise `limit` and run `piloop init`.

| Key | Type | Default | Description |
| - | - | - | - |
| `command` | list\[string] | required | The command (argv) that prints the project's spend so far in USD, as one number. It runs at the repo root with the daemon's environment, which is where its secrets come from. It must exit with code 0 within one minute. Example: `["uv", "run", "scripts/spend.py"]`. |
| `limit` | number | required | The spend in USD at which launches and evaluations stop. |
| `notices` | list\[number] | `[]` | Amounts in USD below `limit`. When the spend passes one, the daemon posts one note to the inbox. |
| `interval` | number | `300` | The number of seconds between two runs of `command`. |

## Memory

`[memory]`: what past campaigns leave for the next ones. See the research memory page.

| Key | Type | Default | Description |
| - | - | - | - |
| `documents` | list\[string] | `[]` | Repo-relative globs of text documents, for example `["docs/evidence/**/*.md"]`: campaign reports, orchestrator logs, design notes. `piloop init` and the stop of the campaign copy them into the `documents` table, where every agent of every project can search them. Never name a document that is derived from validation or test data. |
| `wake_budget` | integer | `400` | The size in tokens (about 4 characters each) of the overview that opens the briefs of workers and research sweeps: the current lesson of each mechanism family on the same repo, from the newest campaign that tried it, one line each. Lessons in the project's own metrics come first (`lessons.metric`), then lessons in related metrics, then the rest. Within those, dead or promising come before untested, then the larger effects of each metric, then newer campaigns: effects in different metrics are never compared by size. The brief then says how to read more. A role can change it with `[roles.<kind>] wake_budget`. 0 turns it off. |

## AnomalyTrigger

`anomaly`: an incident-class anomaly (`piloop.reports.overview`) that no agent of the project got yet.

The row gets the new anomalies, one `anomaly.reported` event each. For the watchdog, that makes it an incident
sweep.

| Key | Type | Default | Description |
| - | - | - | - |
| `kind` | string | `"anomaly"` | |

## CronTrigger

`cron`: the role's `interval` seconds since the start of its last routine row, one that got no anomaly.

The first one counts from the registration of the project. An `interval` of 0 turns it off.

| Key | Type | Default | Description |
| - | - | - | - |
| `kind` | string | `"cron"` | |

## SeedTrigger

`seed`: a seed filed after the role's latest row started, while a slot of the role is free.

It fires however full the backlog is. If the latest row got the seed but was killed, the seed is retried, once per
batch, as `low_water` retries. A seed that a sweep read and did not develop is in the next sweep's brief,
whatever trigger fires it. Gotcha: a sweep's brief is read a moment before its row's `started_at` is set, so a
seed filed in that moment waits for the next sweep.

| Key | Type | Default | Description |
| - | - | - | - |
| `kind` | string | `"seed"` | |

## LowWaterTrigger

`low_water`: the backlog has fewer ideas than its target, and there is a reason to sweep now.

Only the research role uses it. The target is one idea per worker slot plus `[roles.research] buffer`. So ideas
exist before slots free up, and a free slot does not wait a whole sweep for an idea. The ideas that sweeps in
flight can insert already count as backlog. Nothing is requested while every research slot is busy. Runnable
ideas count. Ideas blocked only by prerequisites in flight also count, because they run when those finish. An idea
blocked by a prerequisite that is not running does not count. Below the target, a sweep fires for one of these
reasons:

* The project has no sweep yet, so there is nothing to wait for.
* The last batch was killed. Lost prompts, timeouts or a daemon stop say nothing about the backlog, so the batch
  is retried once. This applies to a streak of killed rows no longer than one batch (`max_concurrent`). A second
  killed batch is a systemic fault and counts as a normal last sweep.
* New results: runs finished since the role's latest row started, which that sweep could not read. A sweep with
  nothing new to read reads the same history and proposes nothing. So each consecutive sweep that defined no idea
  doubles the number of finished runs the next sweep waits for. A sweep that defines an idea resets this. The
  wait is never longer than what can still come: with no run in flight, the finished runs are all there is.
* The backlog is low. Research keeps sweeping while the backlog is short, not only when results come in or a slot
  is idle. A sweep that defined an idea is followed at once. After empty sweeps, research rests 2**n − 1 times as
  long as its latest ended sweep took. n is the number of consecutive ended sweeps, killed ones included, that
  defined no idea. An empty queue never deadlocks, and research that keeps finding nothing uses at most 1/2**n of
  the wall-clock time.

It requests enough sweeps to fill the shortfall at `max_ideas` each, within the role's free slots.

| Key | Type | Default | Description |
| - | - | - | - |
| `kind` | string | `"low_water"` | |

## ScoutTrigger

`scout`: `[roles.research.scout] interval` seconds since the last scout sweep started. Research only.

It requests a scout sweep, a research row in scout mode (`piloop.agents.scout`). It does nothing while the
`[roles.research.scout]` table is not there.

| Key | Type | Default | Description |
| - | - | - | - |
| `kind` | string | `"scout"` | |

## SqlTrigger

`sql`: a query on the piloop database that returns at least one row.

The query may use two parameters: `:project`, the project's id, and `:since`, when the role's latest row
started (or when the project was registered), as UTC text (`2026-01-31 12:00:00.000000`). Compare `:since` with
a `created_at` column to fire only on what is new. The query is checked when `piloop.toml` loads: it must be
one statement that only reads.

| Key | Type | Default | Description |
| - | - | - | - |
| `kind` | string | `"sql"` | |
| `when` | string | required | The query, for example `SELECT 1 FROM candidates WHERE project_id = :project AND verdict = 'keep' AND created_at > :since`. |

## Global settings: `config.toml`

`$PILOOP_HOME/config.toml` holds the settings that every project of one `PILOOP_HOME` shares. To override a key, set `PILOOP_<TABLE>__<KEY>`, for example `PILOOP_STORAGE__MIN_FREE_GB`.

### `[storage]`

`[storage]`: the artifact store every project of this PILOOP\_HOME archives its workspace into.

The keys here hold for every backend. Blobs live in `<PILOOP_HOME>/store`, and in the remote too when
`[storage.remote]` is set. The artifact store page of the docs describes the modes.

| Key | Type | Default | Description |
| - | - | - | - |
| `evict_after_days` | number \| null | `null` | Drop the local copy of a blob the remote holds once it is this old (since it was stored or fetched); 0 drops it as soon as it is uploaded, so the local directory is only a staging area and every read fetches. Unset: by age never. |
| `local_max_gb` | number \| null | `null` | Drop the local copies of the oldest blobs the remote holds until the local store is at most this size. |
| `min_free_gb` | number | `20` | Free space, in GB, that archiving keeps on the disk of the local store. Storing a file writes its compressed copy first, so the daemon removes the workspace copies of archived files, oldest archive first, while the disk has less than this free, and it archives in batches that fit above it. |

`[storage.remote]` picks the backend that mirrors the store by its `kind`, and the backend owns the other keys of the table. The keys of `[storage]` stay the same whatever the backend. The same store, mirrored to S3 or to Google Cloud Storage:

<CodeGroup>
  ```toml S3 theme={null}
  [storage]
  evict_after_days = 30
  min_free_gb = 50

  [storage.remote]
  kind = "s3"
  url = "s3://my-bucket/piloop"
  storage_class = "GLACIER_IR"
  ```

  ```toml GCS theme={null}
  [storage]
  evict_after_days = 30
  min_free_gb = 50

  [storage.remote]
  kind = "gcs"
  bucket = "my-bucket"
  prefix = "piloop"
  storage_class = "COLDLINE"
  credentials_file = "/etc/piloop/gcs-key.json"
  ```
</CodeGroup>

A package adds a backend with an entry point in the `piloop.stores` group: see [storage backends](/architecture/extension-points#storage-backends). The built-in ones:

### `[storage.remote]` with `kind = "s3"`

`[storage.remote]` with `kind = "s3"`: a bucket and prefix, and the storage class of each blob.

| Key | Type | Default | Description |
| - | - | - | - |
| `kind` | string | `"s3"` | The S3 backend. |
| `url` | string | required | `s3://<bucket>[/<prefix>]`: every blob is uploaded there as it is stored. |
| `storage_class` | string | `"GLACIER_IR"` | The S3 storage class of a blob of at least `standard_below` compressed bytes. |
| `standard_below` | integer | `131072` | Compressed size under which a blob is stored as `STANDARD`: Glacier IR bills every object as at least 128 KB. |

### `[storage.remote]` with `kind = "gcs"`

`[storage.remote]` with `kind = "gcs"`: a bucket, a prefix in it, and the storage class of each blob.

| Key | Type | Default | Description |
| - | - | - | - |
| `kind` | string | `"gcs"` | The Google Cloud Storage backend. |
| `bucket` | string | required | The bucket name, without `gs://`. |
| `prefix` | string | `""` | The folder in the bucket that the store's objects go under. Empty: the bucket's root. |
| `storage_class` | `STANDARD` \| `NEARLINE` \| `COLDLINE` \| `ARCHIVE` | `"STANDARD"` | The storage class of every blob. |
| `project` | string \| null | `null` | The Google Cloud project to bill. Unset: the project of the credentials. |
| `credentials_file` | string \| null | `null` | A service account key file (JSON) to use instead of Application Default Credentials. A path, never the key. |


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