How it works
- A mention starts a campaign. A human mentions
@researchin Slack with an objective and a budget. The coordinator confirms the three things every campaign needs — a metric and how to measure it, a target or direction, and a budget — then posts the campaign brief as the first threaded reply. - Rounds fan out. Each round the coordinator proposes 2–4 hypotheses and spawns one experimenter session per hypothesis with the
auto.sessions.spawntool. The spawn message is the experiment brief: the exact variant, the measurement protocol, the baseline, and where to report. - Experimenters measure honestly. Each experimenter gets its own sandbox and checkout, measures the baseline with the brief’s exact protocol, implements the one variant, measures again identically, and reports the verdict — confirmed, refuted, or inconclusive — back to the coordinator’s session with
auto.sessions.message. It never opens PRs during the loop. - The heartbeat advances the loop. Every ten minutes a cron trigger wakes the coordinator to sweep the fleet: nudge quiet experiments, respawn dead ones once, close out rounds whose results are all in, and dispatch the next round.
- The thread is the lab log. After each round the coordinator posts a structured update — every hypothesis with its measured effect, the running best configuration, budget consumed, the next round’s plan. Anyone reading the thread knows exactly where the search stands, and a fresh coordinator session can rebuild the whole campaign state from it.
- Convergence hands off to a human. On completion the coordinator posts a final summary; only after a human approves in the thread does it dispatch one last experimenter to turn the winning variant into a real PR.
The configuration
Three files: a shared runtime fragment and the two agents..auto/fragments/environments/agent-runtime.yaml
.auto/agents/research-coordinator.yaml
.auto/agents/experimenter.yaml
githubApp mounts and a Slack connection named slack.
Walkthrough
The coordinator is a one-slot agent
concurrency: 1 gives the coordinator a single concurrency slot: at most one live session, and deliver triggers with no routeBy resolve to whoever holds it. The three triggers differ only in what happens when the slot is empty:
This shape — deliver-to-slot with a spawning mention — is the standard shape for long-horizon orchestrators. Every mention, reply, and tick lands in one session that can hold several campaigns at once, keyed by their Slack threads. Omitting
routeBy on a deliver trigger is only legal for concurrency: 1 agents (or when a sibling onUnmatched: spawn trigger claims the slot); see runtime controls.
Fan-out is a tool call, not a trigger
The experimenter has exactly one trigger — a courtesy Slack mention handler — because experimenters are not event-driven. The coordinator creates them directly with theauto.sessions.spawn tool, one per hypothesis, and the spawn message is the entire contract: variant, protocol, baseline, reporting address. Two details make the fan-out robust:
- Idempotency keys. The prompt derives
idempotencyKeyfrom campaign thread + round + hypothesis slug.auto.sessions.spawnreturns the existing session instead of creating a duplicate when the same key is replayed — so a heartbeat that re-runs dispatch logic after a crash cannot double-spawn an experiment. - Reports flow upward, not sideways. Experimenters report to the coordinator’s session id with
auto.sessions.message, which injects the report into the coordinator’s transcript as a message. Experimenters never post to Slack; the coordinator is the single voice of the campaign.
auto.sessions.list to sweep the fleet, and the auto.sessions.* family to inspect a suspicious session’s conversation and tool calls. See auto tools.
The heartbeat is the control loop
Thekind: heartbeat trigger creates one cron schedule (10-minute cadence, timezone defaults to UTC) whose ticks deliver into the slot session with a standing instruction: sweep, nudge, respawn once, close rounds, or do nothing. The tick payload carries {{heartbeat.scheduledAt}} so the log line is self-dating. Because the routing is deliver + onUnmatched: drop, the schedule is inert while no campaign session is live — cron advances campaigns, it never starts them. See cron and webhooks.
State lives in the thread, not the session
The lab log convention makes the campaign crash-proof. Everything that matters — the brief, per-round results, the running best — is posted to the campaign thread, and theinitialPrompt tells any fresh session to rebuild from chat.history plus auto.sessions.list before acting. If the coordinator session dies mid-campaign, the next mention spawns a replacement that reads the thread and carries on. Durable state in an external, human-readable artifact beats state in a session’s memory.
Mount asymmetry encodes the division of labor
Both agents mount the same repository with differentgithubApp capabilities:
The coordinator cannot push code no matter what its prompt says — capability boundaries hold even when instructions drift. Its mount also sets
depth: 1 for a fast shallow clone, since it only reads. The experimenter can write, but its GitHub tool list is pared to pull_request_read and create_pull_request, and its prompt reserves PR creation for the explicit post-approval instruction.
Reference-only example
Adapt it
- Replace
acme/widgetsandslack. - The measurement protocol in the experimenter’s brief is where rigor lives: the exact command, warmup, iteration count, and what counts as noise. A loop is only as good as its measurements.
- The heartbeat cadence bounds how fast rounds close. Ten minutes suits experiments that finish in minutes; use hourly for long benchmarks.
- Add spend caps (
spendCaps.maxPerSessionUsdon the experimenter,dailyUsdon the coordinator) before pointing this at expensive campaigns — the fleet multiplies whatever one session costs. - The skeleton is not code-specific: swap the mount and the measurement protocol to run the same loop over eval suites, prompt variants, or document corpora.