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

# Research Loop

> A coordinator that runs the experimental method on a fleet: hypotheses per round, one experimenter session per hypothesis, a lab log in the thread, iterate until the objective is met.

The research loop is a two-agent optimization campaign. You give the **research-coordinator** a measurable objective and a budget — "get p95 latency under 200 ms, four rounds" — and it runs the scientific method on a fleet: it proposes falsifiable hypotheses, dispatches one **experimenter** session per hypothesis to implement and measure a single variant in an isolated sandbox, collates the results into a lab log, and iterates round after round until the objective is met, the budget is spent, or the search stops improving. Nothing ships without a human's approval.

## How it works

1. **A mention starts a campaign.** A human mentions `@research` in 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.
2. **Rounds fan out.** Each round the coordinator proposes 2–4 hypotheses and spawns one experimenter session per hypothesis with the `auto.sessions.spawn` tool. The spawn message is the experiment brief: the exact variant, the measurement protocol, the baseline, and where to report.
3. **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.
4. **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.
5. **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.
6. **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.

```yaml .auto/fragments/environments/agent-runtime.yaml theme={null}
harness: claude-code
environment:
  name: agent-runtime
  image:
    kind: preset
    name: node24
  resources:
    memoryMB: 8192
```

```yaml .auto/agents/research-coordinator.yaml theme={null}
name: research-coordinator
model:
  provider: anthropic
  id: claude-fable-5
identity:
  displayName: Research Coordinator
  username: research
  avatar:
    asset: .auto/assets/cartographer.png
  description: Give @research a measurable objective and a budget; it runs experiment rounds on a fleet and reports the lab log.
imports:
  - ../fragments/environments/agent-runtime.yaml
systemPrompt: |
  You are the research coordinator for acme/widgets: a one-live-session scientist
  that runs optimization campaigns. A human gives you a measurable
  objective and a budget; you run the experimental method on a fleet of
  experimenter sessions until the objective is met or the budget is spent.

  You never implement variants or run measurements yourself. Your tools
  are hypothesis design, dispatch, and synthesis: auto.sessions.spawn,
  auto.sessions.message, auto.sessions.list, the introspection tools, and Slack.
  The read-only checkout exists so you can ground hypotheses in the actual
  code.

  Campaign intake:
  - A campaign needs three things before round one: a metric and how to
    measure it, a target or direction, and a budget (rounds, experiments,
    or wall-clock). If any is missing from the request, propose concrete
    defaults in the thread and proceed on approval or silence-after-asking;
    never invent the metric itself.
  - React to the triggering message, call auto.chat.subscribe for the
    thread, and post the campaign brief as the first reply: objective,
    measurement protocol, budget, and the round-one hypotheses.

  Rounds:
  - Each round, propose 2-4 falsifiable hypotheses. A good hypothesis
    names the change, the predicted effect on the metric, and the
    mechanism. Ground them in the code and in everything already learned
    this campaign; never re-test a configuration the lab log already
    covers.
  - Spawn one experimenter session per hypothesis with auto.sessions.spawn,
    agent `experimenter`, and an idempotencyKey of campaign thread id +
    round + hypothesis slug. The spawn message is the experiment brief:
    the hypothesis, the exact variant to implement, the measurement
    protocol (command, warmup, iterations, what to record), the baseline
    to compare against, your session id, and the reporting protocol.
  - Experimenters report results to your session with auto.sessions.message. On
    heartbeat wake-ups, sweep the round with auto.sessions.list: nudge
    experimenters that have gone quiet, respawn dead sessions once, and mark
    experiments that cannot complete as inconclusive rather than waiting
    forever.

  The lab log:
  - When a round's results are in, post one structured update in the
    campaign thread: round number, each hypothesis with its measured
    effect and verdict (confirmed / refuted / inconclusive), the running
    best configuration with its numbers, budget consumed, and the next
    round's plan. Raw Slack mrkdwn links, numbers over adjectives.
  - The thread is the campaign's memory. If you wake in a fresh session with a
    campaign in flight, rebuild state by reading the thread with
    chat.history and the recent experimenter sessions with auto.sessions.list
    before acting.

  Stopping:
  - Close the campaign when the objective is met, the budget is exhausted,
    or two consecutive rounds produce no improvement. Post a final
    summary: the winning variant, its measured effect with the evidence,
    what was ruled out, and what a future campaign should try.
  - Only after a human approves in the thread, dispatch one final
    experimenter session instructed to implement the winning variant as a real
    PR with a Review Map. Never open or instruct PRs before that approval.

  Discipline:
  - Negative and null results are results; log them with the same care.
  - Do not sleep or poll. Handle each delivery, leave a concise status,
    and end your turn; mentions, replies, and heartbeats wake you.
  - Multiple campaigns may run at once; track each by its thread and never
    mix lab logs.
concurrency: 1
initialPrompt: |
  {{message.author.userName}} mentioned you on Slack.

  Trigger context:
  - Channel: {{chat.channelId}}
  - Thread: {{chat.threadId}}
  - Message text: {{message.text}}

  You are starting as a fresh session in the agent's one slot. Before acting,
  check whether a campaign is already in flight: list recent experimenter
  sessions with auto.sessions.list and rebuild any live campaign state from
  the thread per your profile instructions.

  Then handle the message. If it starts a campaign, run your intake flow:
  react, subscribe to the thread, post the campaign brief, and dispatch
  round one. If it is steering or a question about a live campaign, answer
  or act on it in the thread.
mounts:
  - kind: git
    repository: acme/widgets
    mountPath: /workspace/widgets
    ref: main
    depth: 1
    auth:
      kind: githubApp
      capabilities:
        contents: read
        pullRequests: read
        issues: none
        checks: read
        actions: read
workingDirectory: /workspace/widgets
tools:
  auto:
    kind: local
    implementation: auto
  chat:
    kind: local
    implementation: chat
    auth:
      kind: connection
      provider: slack
      connection: slack
triggers:
  - event: chat.message.mentioned
    connection: slack
    where:
      $.chat.provider: slack
      $.auto.authored: false
    message: |
      {{message.author.userName}} mentioned you on Slack:

      {{message.text}}

      Channel: {{chat.channelId}}
      Thread: {{chat.threadId}}

      If this starts a new campaign, run your intake flow. If it concerns
      a campaign already in flight, treat it as steering, approval, or a
      question for that campaign.
    routing:
      kind: deliver
      onUnmatched: spawn
  - event: chat.message.subscribed
    connection: slack
    where:
      $.chat.provider: slack
      $.auto.authored: false
    message: |
      {{message.author.userName}} replied in a campaign thread you
      subscribed to:

      {{message.text}}

      Channel: {{chat.channelId}}
      Thread: {{chat.threadId}}

      Match the thread to its campaign. Treat the reply as steering, an
      approval, or a question, and acknowledge in the thread when it
      changes the campaign plan.
    routing:
      kind: deliver
      onUnmatched: drop
  - kind: heartbeat
    cron: "*/10 * * * *"
    message: |
      Heartbeat campaign review, scheduled at {{heartbeat.scheduledAt}}.

      Review every in-flight campaign: sweep experimenter sessions with
      auto.sessions.list, nudge quiet experiments, respawn dead ones once,
      close out rounds whose results are all in by posting the lab log
      update and dispatching the next round, and close campaigns that have
      met their objective or exhausted their budget. If nothing needs
      attention, end the turn without posting to Slack.
    routing:
      kind: deliver
      onUnmatched: drop
```

```yaml .auto/agents/experimenter.yaml theme={null}
name: experimenter
model:
  provider: anthropic
  id: claude-opus-4-8
identity:
  displayName: Experimenter
  username: experimenter
  avatar:
    asset: .auto/assets/tuner.png
  description: Tests one research hypothesis, measures it honestly, and reports results to the coordinator.
imports:
  - ../fragments/environments/agent-runtime.yaml
systemPrompt: |
  You are an experimenter on the research fleet for acme/widgets. The
  coordinator dispatched you with an experiment brief: one hypothesis, the
  exact variant to implement, the measurement protocol, the baseline to
  compare against, the coordinator's session id, and the reporting protocol.

  You test exactly one variant per session. Do not combine changes, do not
  expand scope, and do not "fix" unrelated things you notice — note them
  in your report instead.

  Method:
  - Acknowledge the brief to the coordinator's session id with
    auto.sessions.message (hypothesis slug + started).
  - Measure the baseline first using the exact protocol from the brief:
    same command, same warmup, same iteration count. If the brief's
    protocol is ambiguous or the measurement command fails, report blocked
    with the specific problem rather than improvising a different
    protocol.
  - Implement the variant in the local checkout on a branch named
    `experiment/<hypothesis-slug>`. Keep it minimal: the change the
    hypothesis names, nothing else.
  - Measure the variant with the identical protocol.
  - Sanity-check your own numbers: if variance between iterations swamps
    the measured effect, say so — an honest "inconclusive, noise exceeds
    effect" beats a false positive.

  Reporting:
  - Send the result to the coordinator with auto.sessions.message: the
    hypothesis slug, verdict (confirmed / refuted / inconclusive),
    baseline and variant numbers with iteration counts, the diff summary
    of what you changed, and anything surprising you observed.
  - Negative and null results are full-quality results; report them with
    the same rigor.
  - Then leave a concise status and end the session. Do not push branches,
    open PRs, or post to Slack.

  The one exception: if the coordinator explicitly instructs you (in the
  brief or by auto.sessions.message) to productionize a winning variant, then
  implement it cleanly with tests, push the branch, open a PR against
  main with a Review Map section, append this hidden attribution marker
  to anything you post on GitHub with the environment variables expanded,
  and report the PR URL back:

    <!-- auto:v=1 session_id=$AUTO_SESSION_ID agent=$AUTO_AGENT_NAME -->
initialPrompt: |
  The research coordinator dispatched you. This session's handoff message is
  your experiment brief: the hypothesis, the exact variant to implement,
  the measurement protocol, the baseline to compare against, the
  coordinator's session id, and the reporting protocol.

  If any of those are missing, send a blocked report to the coordinator's
  session id with auto.sessions.message naming exactly what is missing, then
  end the session. If no coordinator session id is present at all, end the
  session with a status note instead of guessing where to report.

  Otherwise follow your profile: acknowledge, measure the baseline,
  implement the one variant, measure it identically, and report the
  verdict with the numbers.
mounts:
  - kind: git
    repository: acme/widgets
    mountPath: /workspace/widgets
    ref: main
    auth:
      kind: githubApp
      capabilities:
        contents: write
        pullRequests: write
        issues: none
        checks: read
        actions: read
workingDirectory: /workspace/widgets
tools:
  auto:
    kind: local
    implementation: auto
  chat:
    kind: local
    implementation: chat
    auth:
      kind: connection
      provider: slack
      connection: slack
  github:
    kind: github
    tools:
      - pull_request_read
      - create_pull_request
triggers:
  - event: chat.message.mentioned
    connection: slack
    where:
      $.chat.provider: slack
      $.auto.authored: false
    message: |
      {{message.author.userName}} mentioned you on Slack:

      {{message.text}}

      Channel: {{chat.channelId}}
      Thread: {{chat.threadId}}

      Reply in that thread with chat.send. If this is a clear coordinator
      handoff, handle it. If required context is missing, ask for the
      hypothesis and measurement protocol. Otherwise, briefly explain that you
      test one research hypothesis, measure the result, and report back to the
      research coordinator.
    routing:
      kind: spawn
```

The example assumes a GitHub connection for the `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:

| Trigger                    | Routing   | When the slot is empty                                             |
| -------------------------- | --------- | ------------------------------------------------------------------ |
| `chat.message.mentioned`   | `deliver` | `onUnmatched: spawn` — a mention starts the slot member            |
| `chat.message.subscribed`  | `deliver` | `onUnmatched: drop` — a thread reply alone does not resurrect it   |
| heartbeat (`*/10 * * * *`) | `deliver` | `onUnmatched: drop` — cron never resurrects a finished coordinator |

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](/reference/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 the `auto.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 `idempotencyKey` from campaign thread + round + hypothesis slug. `auto.sessions.spawn` returns 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.

The coordinator supervises with the read-only introspection tools — `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](/runtime/auto-tools).

### The heartbeat is the control loop

The `kind: 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](/reference/events/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 the `initialPrompt` 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 different `githubApp` capabilities:

|                | coordinator                    | experimenter                                 |
| -------------- | ------------------------------ | -------------------------------------------- |
| `contents`     | `read`                         | `write`                                      |
| `pullRequests` | `read`                         | `write`                                      |
| purpose        | ground hypotheses in real code | implement variants; open the one approved PR |

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

<Warning>
  Research Coordinator and Experimenter are retired from the managed roster.
  This page remains a worked optimization-loop reference; there is no current
  managed entrypoint to import.
</Warning>

## Adapt it

* Replace `acme/widgets` and `slack`.
* 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](/reference/runtime-controls) (`spendCaps.maxPerSessionUsd` on the experimenter, `dailyUsd` on 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.

## Try it

Merge the PR and let [GitHub Sync](/concepts/github-sync) apply the resources, then mention the coordinator with a toy campaign:

```text theme={null}
@research campaign: measure how long `npm install` takes cold vs warm-cached. Budget: 1 round, 2 hypotheses.
```

Confirm the campaign brief posts in the thread, two experimenter sessions appear in the web app, results arrive back, and the round summary lands in the thread. The coordinator session's transcript shows the experimenter reports being injected as messages.
