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

# Triggers

> Complete reference for the triggers block: event selection, where filters, routing (spawn, deliver, bind), trigger messages, PR checks, and concurrency interaction.

The `triggers:` array on an agent declares which events wake it and what happens when they arrive: start a fresh session, deliver into a live one, or continue the session bound to the event's target. This page is the exhaustive field reference for that block. For the conceptual walkthrough, read [triggers and events](/concepts/triggers-and-events); for the payload of each event, see the [event catalog](/reference/events/github).

A trigger has three parts: **what to listen for** (`event`, `where`, `connection`), **what to say** (`message` or `attachedUserPrompt`), and **where to send it** (`routing`).

```yaml .auto/agents/pr-review.yaml theme={null}
triggers:
  - events:
      - github.pull_request.opened
      - github.pull_request.reopened
      - github.pull_request.synchronize
    connection: github-acme
    where:
      $.github.repository.fullName: acme/widgets
    routing:
      kind: spawn
      bind:
        target: github.pull_request
  - event: github.issue_comment.created
    connection: github-acme
    where:
      $.github.repository.fullName: acme/widgets
      $.github.auto.authored: false
    message: |
      A new comment arrived on PR #{{github.pullRequest.number}}:

      {{github.issueComment.body}}
    routing:
      kind: bind
      target: github.pull_request
      onUnmatched: drop
```

## Two trigger shapes

Every entry in `triggers:` is one of two shapes:

* **Event trigger** — listens on one `event` (or a list of `events`) from GitHub, chat, Linear, a custom webhook endpoint, or auto's own lifecycle.
* **Heartbeat trigger** — `kind: heartbeat` with a `cron` expression; no `event` at all. See [heartbeat triggers](#heartbeat-triggers).

Both shapes are validated strictly: an unknown key inside a trigger fails apply.

## Event trigger fields

<ParamField path="triggers[].event" type="string">
  The event key to listen on, e.g. `github.pull_request.opened` or `chat.message.mentioned`. Trimmed, non-empty. Exactly one of `event` or `events` is required — declaring both fails with "Use either event or events, not both", and declaring neither fails with "Trigger requires event or events".
</ParamField>

<ParamField path="triggers[].events" type="string[]">
  A list of event keys sharing this trigger's filter, message, and routing. At apply time the definition expands into one stored trigger per key; entries must be unique. Use this when several lifecycle events deserve identical handling:

  ```yaml theme={null}
  - events:
      - chat.reaction.added
      - chat.reaction.removed
    routing:
      kind: deliver
      onUnmatched: drop
  ```
</ParamField>

<ParamField path="triggers[].connection" type="string">
  The name of the [connection](/concepts/connections-and-identities) resource this trigger listens through — the GitHub installation, Slack workspace, or Linear organization that scopes it. `github.*`, `linear.*`, and `chat.*` events must resolve to an active connection at apply time: name it here, or omit `connection` when the project has exactly one matching connection. Zero matching connections — or several, with no name to disambiguate — fails apply. `auto.*` and `webhook.*` events take no connection. See [event key prefixes](#event-selection).
</ParamField>

<ParamField path="triggers[].optional" type="boolean">
  When `true`, apply silently skips this trigger if the referenced `connection` has no active grant instead of failing the whole apply. The skipped trigger is omitted from the applied agent and re-activates on the next apply or GitHub Sync run once the connection is set up. Useful in shared fragments and templates that reference connections a project may not have yet.
</ParamField>

<ParamField path="triggers[].endpoint" type="string">
  Names a custom webhook endpoint for `webhook.*` events. Apply creates or adopts the endpoint and returns a receipt containing its ingest URL (`POST /api/v1/webhook-endpoints/{slug}/events`). Pair with `auth` when the endpoint does not exist yet. See [cron and webhooks](/reference/events/cron-and-webhooks) for the full ingest contract.
</ParamField>

<ParamField path="triggers[].auth" type="object">
  How callers of a custom webhook `endpoint` authenticate. A discriminated union on `kind`:

  | `kind`         | Extra fields | Caller sends                                                   |
  | -------------- | ------------ | -------------------------------------------------------------- |
  | `hmac_sha256`  | `secretRef`  | `x-auto-signature-256` header: hex HMAC-SHA256 of the raw body |
  | `bearer_token` | `secretRef`  | `Authorization: Bearer <secret>`                               |
  | `none`         | —            | nothing                                                        |

  `secretRef` names a project [secret](/reference/secrets) that holds the signing key or token. Creating a new endpoint requires `auth` — applying a trigger that names a nonexistent endpoint without one fails with "Webhook endpoint … does not exist; include auth to create it". A trigger reusing an existing endpoint may omit `auth` (the endpoint's stored auth applies); declaring auth that differs from the stored endpoint fails apply.

  ```yaml theme={null}
  - event: webhook.incident.opened
    endpoint: incident-webhook
    auth:
      kind: bearer_token
      secretRef: incident-webhook-secret
    routing:
      kind: spawn
  ```
</ParamField>

<ParamField path="triggers[].where" type="object" default="{}">
  A map of payload paths to match clauses. All clauses must match (logical AND); an empty or omitted `where` matches every event on the key. See [where filters](#where-filters).
</ParamField>

<ParamField path="triggers[].message" type="string">
  A template (1–20,000 characters) rendered against the normalized event payload and delivered to the session as the triggering message. `{{dot.path}}` tokens resolve into the payload — `{{github.pullRequest.number}}`, `{{message.text}}`. Templates prefixed `{{payload.…}}` are rejected at apply time; the payload is the template root. See [trigger messages](#trigger-messages) for rendering rules and what happens when `message` is omitted.
</ParamField>

<ParamField path="triggers[].attachedUserPrompt" type="string">
  A verbatim (non-template) start message, 1–20,000 characters of non-whitespace text. Legal only on `spawn`-routed triggers — any other routing kind fails apply with "Attached user prompt is supported only for spawn routing". When set, it replaces the delivered start message, and the rendered `message` template is demoted to hidden context. See [attached user prompts vs. hidden trigger context](#attached-user-prompts-vs-hidden-trigger-context).
</ParamField>

<ParamField path="triggers[].checks" type="object[]">
  GitHub check runs this trigger manages on the pull request that fired it. Legal only when every event on the trigger starts with `github.pull_request.` — anything else fails apply with "Trigger checks are only supported for GitHub pull request events". Each entry:

  | Field                      | Type   | Required | Constraints                                                                                                                                                       |
  | -------------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `name`                     | string | yes      | resource name (1–128 chars, `[A-Za-z0-9_.-]`), unique across the trigger's checks                                                                                 |
  | `displayName`              | string | yes      | 1–256 chars, unique across the trigger's checks                                                                                                                   |
  | `description`              | string | yes      | 1–65,535 chars                                                                                                                                                    |
  | `instructions`             | string | no       | 1–20,000 chars, delivered to the session with the check                                                                                                           |
  | `timeout` / `beginTimeout` | object | no       | `{ seconds: 1..604800, conclusion: success \| failure \| skipped }` — creation-to-begin deadline; `timeout` and `beginTimeout` are aliases and mutually exclusive |
  | `completeTimeout`          | object | no       | same shape — begin-to-complete deadline                                                                                                                           |

  See [PR checks](#pr-checks) for the runtime lifecycle.
</ParamField>

<ParamField path="triggers[].fallback" type="boolean" default="false">
  Marks this trigger as a catch-all: it fires only when no non-fallback trigger on the same event key (and webhook endpoint) matched its `where`. The fallback trigger's own `where` still applies as an extra gate. See [fallback triggers](#fallback-triggers).
</ParamField>

<ParamField path="triggers[].routing" type="object" required>
  What to do with a matched event: `kind: spawn`, `kind: deliver`, or `kind: bind`. See [routing](#routing).
</ParamField>

<ParamField path="triggers[].name" type="string">
  Authoring-only identity for [import merging](/reference/imports-and-fragments): triggers merge across imports by `name`, falling back to `event`, then the joined `events` list, then `cron:<cron>:<timezone>`. The `name` is stripped before validation and never stored. `remove.triggers` deletes by the same key.
</ParamField>

## Heartbeat triggers

A heartbeat trigger wakes the agent on a schedule instead of an external event. Apply creates one Temporal Schedule per heartbeat trigger and routes each tick through the normal trigger pipeline under a synthetic event key (`heartbeat.<agentResourceId>.<ordinal>`) that only this trigger can match.

<ParamField path="triggers[].kind" type="&#x22;heartbeat&#x22;" required>
  Discriminator selecting the heartbeat shape.
</ParamField>

<ParamField path="triggers[].cron" type="string" required>
  The cron expression, 1–512 characters. auto passes the string verbatim to the Temporal Schedule, so it accepts what the Temporal server accepts — classic 5-field cron (`*/15 * * * *`, `0 9 * * 1-5`) is the form used in production configs.
</ParamField>

<ParamField path="triggers[].timezone" type="string" default="UTC">
  IANA timezone the cron expression is evaluated in, 1–128 characters.
</ParamField>

`message`, `where`, `fallback`, and `routing` work exactly as on event triggers. `event`, `events`, `connection`, `optional`, `endpoint`, and `auth` are forbidden on a heartbeat, and `checks` is rejected because a heartbeat is not a pull-request event.

The tick payload is `{ trigger: "heartbeat", heartbeat: { scheduleId, agentResourceId, triggerOrdinal, scheduledAt } }`, so `{{heartbeat.scheduledAt}}` is the useful template token. Ticks deduplicate on `<scheduleId>:<scheduledAt>`.

```yaml .auto/agents/chief-of-staff.yaml theme={null}
- kind: heartbeat
  cron: "*/15 * * * *"
  message: |
    Heartbeat fleet review, scheduled at {{heartbeat.scheduledAt}}.

    Review every in-flight batch and nudge stalled sessions. If nothing
    needs attention, end the turn.
  routing:
    kind: deliver
    onUnmatched: drop
```

Here `onUnmatched: drop` is deliberate: when the agent's one live session has archived itself between batches, the cron tick must not resurrect it.

## Event selection

Event keys are dot-separated strings. The first segment names the source and decides what the trigger needs at apply time:

| Prefix     | Source                                                                                                               | Apply-time requirement                                                                      |
| ---------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `github.`  | [GitHub events](/reference/events/github)                                                                            | an active GitHub connection (`connection` names it; may be omitted when exactly one exists) |
| `linear.`  | [Linear events](/reference/events/linear)                                                                            | an active Linear connection (same naming rule)                                              |
| `chat.`    | [Slack](/reference/events/slack), Discord, and [Telegram](/reference/events/telegram) messages, edits, and reactions | an active chat-provider connection — Slack, Discord, or Telegram (same naming rule)         |
| `webhook.` | [Custom webhook endpoints](/reference/events/cron-and-webhooks)                                                      | `endpoint` (+ `auth` when the endpoint does not exist yet)                                  |
| `auto.`    | [Platform lifecycle events](/reference/events/lifecycle) — applies, connections, session bindings, onboarding        | nothing                                                                                     |

Any other prefix fails apply with `Unsupported trigger event provider`.

<Warning>
  Beyond the prefix, event keys are **not** validated against a closed catalog. A typo'd key (`github.pull_request.close` instead of `.closed`) applies cleanly and simply never fires. Check keys against the [event catalog](/reference/events/github) when a trigger seems dead.
</Warning>

## Where filters

`where` narrows a trigger to the payloads it should act on. Keys are payload paths; values are match clauses. Every clause must match for the trigger to fire.

**Path grammar.** A key is either a single bare key with no dots (`type`) or a `$.`-prefixed dot path (`$.github.action`, `$.linear.issue.labelNames`). Segments must be non-empty and free of surrounding whitespace. Anything else fails apply with "Trigger filter paths must be a single bare key or use \$.path.segments".

**Clause forms.** A clause is a bare scalar for strict equality, or an object with exactly one operator:

| Clause                                        | Matches when                                                                                    |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `<scalar>` (string, number, boolean, or null) | the value at the path strictly equals the scalar                                                |
| `{ contains: <scalar> }`                      | the value is an array containing the scalar                                                     |
| `{ exists: true }` / `{ exists: false }`      | the value is defined / undefined                                                                |
| `{ in: [<scalar>, …] }`                       | the value is a scalar included in the list (min 1 entry)                                        |
| `{ notIn: [<scalar>, …] }`                    | the value is **undefined**, or a scalar not in the list (min 1 entry)                           |
| `{ changedTo: <scalar> }`                     | the value equals the scalar **and** the sibling `previous.*` path exists with a different value |

Details worth knowing:

* A path that traverses a missing or non-object segment resolves to undefined. Undefined never satisfies equality, `contains`, or `in` — but it **does** satisfy `{ exists: false }` and `notIn`. That makes `notIn` the right operator for excluding a value while still matching older payloads that predate the field:

  ```yaml theme={null}
  where:
    # Skip runs whose head was superseded; keep matching events that
    # predate the headIsCurrent field.
    $.github.checkRun.headIsCurrent:
      notIn:
        - false
  ```

* `changedTo` derives its comparison path by inserting `previous` before the final segment: `$.a.b.c` reads `$.a.b.previous.c`. It only works on payloads that actually carry such a sibling object; most sources do not. For Linear label transitions, use `$.linear.updatedFrom.…` with `contains` instead (see [Linear events](/reference/events/linear)).

* Filters match against the normalized payload documented per event in the catalog, not the raw provider webhook — though the raw body is usually available under `$.raw.…`. For custom webhooks, the payload is the raw request body, so paths are whatever the caller posts.

Common filter idioms from production configs:

```yaml theme={null}
where:
  $.chat.provider: slack          # one chat provider only
  $.auto.authored: false          # ignore auto's own messages (echo suppression)
  $.github.repository.fullName: acme/widgets
  $.github.review.state:
    in: [approved, changes_requested]
  $.linear.issue.labelNames:
    contains: chief-dispatch
```

## Routing

`routing.kind` selects one of three behaviors. All three funnel into the same decision: create a new session, deliver a message into one or more existing sessions, or record the event as unmatched.

### `kind: spawn`

Start a fresh session carrying the event. Each matched event creates one session (idempotent per event + trigger, so a retried delivery never double-spawns).

<ParamField path="routing.bind" type="object">
  Optional bind-at-spawn: `{ target, context?, eventContext? }`. After creating the session, the router writes a session binding for the event's routing target of the given type, so later `bind`-routed events resolve back to this session. `target` is one of the [binding target types](#bind-targets); `context` is a JSON object stamped on the binding relationship; `eventContext` annotates the emitted `auto.session.binding.bound` transition.

  ```yaml theme={null}
  - event: github.pull_request.opened
    connection: github-acme
    routing:
      kind: spawn
      bind:
        target: github.pull_request
  ```

  The bind block also accepts inline `lifecycle: manual | held` and `continuity: session | agent`, which compile into the agent's `bindings:` map (a conflicting explicit declaration for the same target and field fails apply). `agent.singleton` cannot take inline policy.
</ParamField>

Spawn races convert to deliveries rather than failing or duplicating: when a concurrent session already claimed the chat thread, the agent's concurrency slot, or the trigger's continuation target, the spawn is converted into a delivery to the winning session, so the event is never lost and never handled twice.

### `kind: deliver`

Deliver the event as a message into existing live session(s).

<ParamField path="routing.routeBy" type="object">
  The resolution strategy:

  | `routeBy.kind`       | Delivers to                                                                                                                                                                                                                                 |
  | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | *(omitted)*          | the agent's one concurrency-slot member — legal only when the agent declares `concurrency: 1` or a slot delivery on the agent (this trigger included) carries `onUnmatched: spawn`; see [concurrency interaction](#concurrency-interaction) |
  | `attributedSessions` | the session(s) bound to the event's chat thread — the follow-up fan-out for threads a session participates in                                                                                                                               |
  | `allLiveSessions`    | every live session of this agent                                                                                                                                                                                                            |

  Legacy spellings `allLiveRuns` and `attributedRuns` still parse and normalize to the canonical kinds.
</ParamField>

<ParamField path="routing.onUnmatched" type="string" default="drop">
  Policy when no live session resolves: `drop`, `warn`, `error`, or `spawn`. See [onUnmatched](#onunmatched).
</ParamField>

<ParamField path="routing.bind" type="object">
  Deliver-then-bind sugar: `{ target, lifecycle?, continuity? }` on a deliver arm means the delivered session also binds the event's chat thread. The target must be a chat-thread target (`slack.thread` today), and the declaration compiles `bind: onMention` into the agent's `bindings:` map. Used on mention triggers so the thread routes future events back to whichever session handled the mention:

  ```yaml theme={null}
  - event: chat.message.mentioned
    connection: slack
    where:
      $.chat.provider: slack
      $.auto.authored: false
    routing:
      kind: deliver
      onUnmatched: spawn
      bind:
        target: slack.thread
        continuity: agent
  ```
</ParamField>

Two authoring rules protect chat deliver triggers from feedback loops, enforced at apply time:

* A `chat.message.*` trigger with `routeBy: attributedSessions` must filter `"$.auto.authored": false` — otherwise the agent's own replies in the thread would route back into it.
* A spawn trigger and an `attributedSessions` deliver trigger on the same event must use mutually exclusive `"$.auto.attributions"` filters: the spawn requires `{ exists: false }`, the delivery `{ exists: true }`. Otherwise one message could both spawn a new session and deliver into the existing one.

```yaml theme={null}
# Spawn on first contact, deliver follow-ups to the attributed session.
- event: chat.message.mentioned
  connection: slack
  where:
    $.chat.provider: slack
    $.auto.authored: false
    $.auto.attributions:
      exists: false
  routing:
    kind: spawn
- events:
    - chat.message.mentioned
    - chat.message.subscribed
  connection: slack
  where:
    $.chat.provider: slack
    $.auto.authored: false
    $.auto.attributions:
      exists: true
  routing:
    kind: deliver
    routeBy:
      kind: attributedSessions
    onUnmatched: drop
```

### `kind: bind`

Resolve the event's routing target — the PR, issue, or thread the event is about — to the session bound to it for this agent, and deliver there. This is the continuation primitive: one session owns a PR across pushes, comments, reviews, and check results.

<ParamField path="routing.target" type="string" required>
  The binding target type to resolve: `github.pull_request`, `github.issue`, `slack.thread`, `agent.singleton`, `linear.issue`, or `auto.session`.
</ParamField>

<ParamField path="routing.onUnmatched" type="string" default="drop">
  Policy when no session is bound to the target. `spawn` gives the "bind or start fresh" pattern: the first PR event spawns a reviewer, and every later event folds into it.
</ParamField>

<ParamField path="routing.release" type="boolean | object" default="false">
  Declarative deliver-then-release: after the router routes this event, the platform releases the target's active binding as a follow-up. The bound session receives the message first; `true` normalizes to `{ context: null }`, and `{ context: { … } }` annotates the release. A no-op when no active binding exists. Rejected on `target: agent.singleton` (the concurrency slot is reconciler-owned, not trigger-releasable). Required companion of `lifecycle: held` bindings: a held target must have at least one bind-routed trigger with `release: true` so the platform can let go of it.
</ParamField>

<ParamField path="routing.observedTarget" type="object">
  Observer machinery for agents that watch other sessions. Legal only when `target: auto.session` **and** the trigger's event is one of `auto.session.binding.bound`, `auto.session.binding.updated`, or `auto.session.binding.unbound` — the [lifecycle events](/reference/events/lifecycle) whose payloads carry the observed `binding.target`. Discriminated on `action`:

  * `{ action: "bind", context?, eventContext? }` — after delivering the transition to the observer session, the platform binds that observer to the target carried by the observed event.
  * `{ action: "unbind", eventContext? }` — releases the observer's own active claim on that target (idempotent when absent; another session's claim is left untouched).

  This is how an orchestrator adopts a worker's PR the moment the worker declares it ready, without polling:

  ```yaml theme={null}
  - event: auto.session.binding.updated
    where:
      $.binding.target.type: github.pull_request
      $.binding.context.phase: ready-for-final-review
    routing:
      kind: bind
      target: auto.session
      onUnmatched: drop
      observedTarget:
        action: bind
        context:
          role: human-review-shepherd
  ```
</ParamField>

The bind arm also accepts inline `lifecycle` and `continuity` directly on the routing object, folded into `bindings:` the same way as the spawn sugar.

<Note>
  Legacy routing spellings from older configs and pinned template versions still parse and normalize: `deliver` + `routeBy: { kind: singleton }` becomes bare `deliver`; `deliver` + `routeBy: { kind: ownedArtifact, artifactType }` becomes `bind` over that target; `kind: deliverOrSpawn` becomes `deliver` + `onUnmatched: spawn`. Write the canonical forms in new configs.
</Note>

### Bind targets

At ingest, every event derives its routing target fail-safe from its payload:

| Events                                                                                                | Target carried                                                             |
| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `github.pull_request.*`, PR comments/reviews/checks                                                   | `github.pull_request`                                                      |
| `github.issue.*`, issue comments                                                                      | `github.issue`                                                             |
| `linear.issue.*`                                                                                      | `linear.issue`                                                             |
| `chat.message.*`, `chat.reaction.*`                                                                   | `slack.thread` (the canonical chat-thread target, one per provider thread) |
| `auto.session.binding.*`                                                                              | `auto.session` (the session whose binding changed)                         |
| `github.push`, `github.workflow_run.completed`, `github.commit_comment.created`, heartbeats, webhooks | none                                                                       |

An event with no derivable target can never match a `bind` route — it always lands on `onUnmatched`. Route those events with `spawn` or `deliver` instead. The per-event catalog pages list the target for every key.

Sessions acquire bindings through bind-at-spawn (`routing.bind` on a spawn trigger), deliver-then-bind sugar, `observedTarget` actions, and the session calling the `auto.bind` runtime tool itself (see [auto tools](/runtime/auto-tools)). Per-target policy — `lifecycle`, `continuity`, auto-bind modes — lives in the agent's `bindings:` map, documented in the [agent file reference](/reference/agent-file).

### onUnmatched

`deliver` and `bind` routes name a policy for events that resolve to no live session:

| Policy           | Effect                                       |
| ---------------- | -------------------------------------------- |
| `drop` (default) | record the event as unmatched and do nothing |
| `warn`           | record as unmatched with a warning           |
| `error`          | record as unmatched with an error            |
| `spawn`          | start a fresh session carrying the event     |

`onUnmatched: spawn` is legal for any agent. On a concurrency-capped agent, an unmatched slot delivery's spawn additionally claims the agent slot inside the session-creation transaction — and converts to a delivery if a concurrent spawn wins the slot first, so the cap holds.

Choose the policy per event, not per agent. From the production chief-of-staff config: a human reply in a subscribed thread uses `onUnmatched: spawn` (a message during a replacement window must never drop — it spawns the successor carrying it), while a lone emoji reaction uses `onUnmatched: drop` (never boot a whole session for one reaction).

## Trigger messages

### Rendering

`message` templates render `{{dot.path}}` tokens against the normalized event payload. Strings, numbers, and booleans interpolate directly; objects and arrays are JSON-stringified; a missing or null path renders as an empty string. Whitespace inside braces is tolerated: `{{ github.action }}` equals `{{github.action}}`.

The template root **is** the payload — write `{{github.pullRequest.number}}`, never `{{payload.github.pullRequest.number}}`. The `payload.` prefix is rejected at apply time (it is correct only in mount `ref` templates, which render against a different wrapper — see [mounts](/reference/mounts)).

Delivered messages get useful context appended automatically when the rendered text doesn't already contain it: chat events append `Channel:`, `Thread:`, and `Message:` id lines plus one `Attachment:` line per file; GitHub comment and review events append `Attachment:` lines naming the brokered download tool for GitHub-hosted files.

### What a spawned session receives

For a `spawn` route (including `onUnmatched: spawn`), the start message resolves in this order:

1. `attachedUserPrompt`, verbatim, when the trigger declares one.
2. The rendered `message` template — except on a `bind` route's unmatched spawn, where a defined agent `initialPrompt` wins instead: a bind trigger's `message` is continuation text for an already-briefed session, so first contact starts from the agent's own kickoff prompt.
3. The agent's `initialPrompt`, rendered against the same payload.
4. Text recovered from the payload itself (a chat message's text and author, or a reaction description).
5. A generic fallback instructing the agent to review the triggering event and act.

A trigger always produces a working start message — a fired trigger never strands its event on a session that boots and waits.

For `deliver` and `bind` routes into a live session, the rendered `message` (or the payload-derived fallback chain, when `message` is omitted) is injected as a message into the running conversation.

### Attached user prompts vs. hidden trigger context

`message` and `attachedUserPrompt` fill different roles on a spawn trigger:

* **`message`** is operational text: event data plus handling instructions, written for the agent.
* **`attachedUserPrompt`** is the text a human actually typed — a form submission, a command, a request captured upstream — that should appear as the session's user prompt, untouched by templating.

When both are set, the session starts with `attachedUserPrompt` as its visible first message, and the rendered `message` template is stored as trigger-message context — injected into the session's system prompt under a "Trigger message context" heading, marked as hidden context the trigger supplied. The agent sees both; the conversation transcript leads with the human's words. The attached prompt (rather than the trigger machinery text) also seeds the session's generated display title.

### Interrupt vs. deferred injection

A message delivered into a live session normally **interrupts** the in-flight turn so the event steers immediately. Two GitHub event types instead **defer** until the session is idle: `github.check_run.completed` and `github.workflow_run.completed`. CI results are not worth aborting a mid-turn tool call over; check *action button* events (`github.check_run.requested_action.*`) still interrupt because a human pressed a button. This mapping is fixed per event type and not configurable.

## PR checks

A trigger on `github.pull_request.*` events can declare `checks:` — GitHub check runs that auto creates on the PR and the routed session reports on. This puts the agent's verdict in the merge box, next to CI.

The lifecycle:

1. When the trigger routes an event, auto creates each declared check on the PR head in `queued` state.
2. The session receives four `checks.*` runtime tools: `checks.list`, `checks.begin`, `checks.success`, and `checks.failure`. The check's `instructions` tell the agent when to call them.
3. `checks.begin` moves the check to in-progress; `checks.success` / `checks.failure` conclude it with a title and summary shown on GitHub.
4. Timeouts backstop an agent that never reports. `beginTimeout` (alias `timeout`) runs from check creation until `checks.begin`; `completeTimeout` runs from begin until conclusion. When a deadline expires, the check auto-completes with the configured `conclusion` (`success`, `failure`, or `skipped`) and a "Check begin timed out" / "Check completion timed out" title.

A new qualifying event on the same PR (for example a push) supersedes the previous check cycle and starts a fresh one, so the check always reflects the current head.

```yaml .auto/agents/pr-review.yaml theme={null}
- events:
    - github.pull_request.opened
    - github.pull_request.reopened
    - github.pull_request.synchronize
  connection: github-acme
  where:
    $.github.repository.fullName: acme/widgets
  checks:
    - name: pr-review
      displayName: Auto PR review
      description: Auto reviews this pull request and reports whether blocking issues were found.
      instructions: |
        Call checks.begin with { "name": "pr-review" } before doing anything
        else. After posting the review comment, call checks.success for a
        thumbs-up recommendation or checks.failure for thumbs-down.
      beginTimeout:
        seconds: 1200
        conclusion: failure
      completeTimeout:
        seconds: 1200
        conclusion: failure
  routing:
    kind: spawn
```

## Fallback triggers

For each incoming event, the router first evaluates every normal trigger on that event key (scoped to the same webhook endpoint, for `webhook.*` events). Only when **none** of them matched their `where` filters do the `fallback: true` triggers get a chance — and each fallback's own `where` still applies.

This is the one cross-trigger negation the per-payload matcher cannot express ("nothing else handled this"), which is why it is an explicit flag rather than an empty filter. The main use is custom webhook endpoints receiving unshaped payloads: specific triggers match known event shapes, and a fallback on `webhook.received` catches everything else for triage. See [cron and webhooks](/reference/events/cron-and-webhooks).

## Concurrency interaction

Triggers and the agent's `concurrency` cap are designed together; see [runtime controls](/reference/runtime-controls) for the cap itself.

* **Slot delivery.** A `deliver` route with no `routeBy` resolves "the agent's one live session" via the agent-wide `agent.singleton` binding. Apply rejects it unless the agent declares `concurrency: 1` or some slot delivery on the agent carries `onUnmatched: spawn` (whose spawn claims the slot itself): without a cap, "the one live session" is not a defined thing.
* **Slot claiming.** On a capped agent, every spawn path — a `spawn` route, or an unmatched slot delivery's `onUnmatched: spawn` — claims the slot in the session-creation transaction. A spawn that loses the claim race converts into a delivery to the winner, so events are neither dropped nor duplicated and the cap is never exceeded.
* **Wind-down semantics.** When the slot member archives itself, the next slot delivery is unmatched and its `onUnmatched` policy decides what happens: `spawn` triggers (mentions, new work) revive the agent with a fresh session carrying the event; `drop` triggers (heartbeats, reactions) let it rest.
* **Fan-out routes are uncapped reads.** `routeBy: allLiveSessions` and `attributedSessions` deliver to whatever live sessions exist; on a `concurrency: 1` agent that is at most one.

A `replace: auto` agent adds one more interaction: platform-initiated replacement of the slot member briefly leaves no live session, so any human-facing slot delivery should carry `onUnmatched: spawn` to guarantee messages arriving in that window spawn the successor instead of dropping.
