Skip to main content
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

Metric

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

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.

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.

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.

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.

RoleConfig

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

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.

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.

WatchdogRole

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

Trigger

One entry of [roles.<name>] triggers: { kind = "<trigger>", <parameter> = <value> }. The daemon checks the entries in the order of the list.

ResearchRole

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

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.

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.

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.

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.

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.

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.

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.

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 2n − 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/2n of the wall-clock time.
It requests enough sweeps to fill the shortfall at max_ideas each, within the role’s free slots.

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.

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.