---
title: Triggers
description: A trigger starts a session on a schedule, from a webhook, from an app event, or from a monitor.
---

A trigger starts a [session](/docs/work/sessions) with no person present. Use a trigger to automate recurring or event-driven work.

To follow up on one session's task later — "check tomorrow whether they replied" — use a [reminder](/docs/connect/reminders) instead. A reminder is a trigger scoped to that session. It lives in the database, not `beeuni.yaml`.

## Trigger types

Beeuni supports four trigger types.

- **cron** — runs on a schedule you set.
- **webhook** — runs when an external service sends a signed request to the project's webhook URL.
- **event** — runs when something happens in a connected app, such as a new pull request or a new email. See [App event triggers](#app-event-triggers).
- **monitor** — runs a command from your repository 24/7. Each line the command prints to stdout fires the trigger. Experimental: see [Monitors](#monitors).

You define triggers in the project manifest, `beeuni.yaml`. Each trigger holds a prompt that renders as the fired session's first message. Runtime state — such as the last fire time and status — lives outside the manifest, in the database. Firing a trigger does not create a commit.

Creating, updating, or deleting a trigger through the dashboard writes directly to the default branch. It does not go through a [change request](/docs/work/change-requests) (CR). Editing `beeuni.yaml` inside a session and running `beeuni ship` follows the normal branch and CR flow instead.

## Set up a cron trigger

1. **Add the trigger**

    ```sh
    beeuni triggers add daily-digest --type cron \
      --cron "0 0 9 * * 1-5" --timezone America/Los_Angeles \
      --prompt "Summarize yesterday's activity and save it as a daily note."
    ```

    `cron` is a 6-field expression: second, minute, hour, day, month, weekday. A trigger fires at most once a minute: `0 */30 * * * *` is every 30 minutes, and `*/30 * * * * *` (every 30 seconds) is refused. This command edits your local `beeuni.yaml` only.

2. **Ship it**

    ```sh
    beeuni ship
    ```

    `beeuni ship` commits `beeuni.yaml` and pushes it. The schedule goes live once this lands on your project's default branch.

3. **Confirm it runs**

    ```sh
    beeuni triggers ls
    ```

    The list shows each trigger's slug, state, and when it last fired. To fire it now instead of waiting for the schedule, run `beeuni triggers fire daily-digest`. The command waits for the run's outcome and exits non-zero with the failure text when the run fails; `--wait <dur>` bounds the wait (default `90s`).

## Set up a webhook trigger

A webhook trigger needs a secret. Beeuni uses it to check the signature on every incoming request.

1. **Add the secret**

    ```sh
    beeuni secrets set WEBHOOK_SECRET=<a-random-value>
    ```

    See [Secrets](/docs/project/secrets) for more on secrets.

2. **Add the trigger**

    ```sh
    beeuni triggers add new-lead --type webhook \
      --secret-env WEBHOOK_SECRET \
      --prompt "A new lead arrived: {{ body.name }} ({{ body.email }}). Add it to the CRM."
    ```

    `--secret-env` names the secret that signs requests to this trigger.

3. **Ship it**

    ```sh
    beeuni ship
    ```

4. ### Send it a request

    Beeuni builds the webhook URL from your project id and the trigger's slug:

    ```
    POST /v1/webhooks/projects/<project-id>/<slug>
    ```

    Send a signed `POST` request to this URL from the external service. Beeuni checks the signature against `WEBHOOK_SECRET`, then starts a session with the request body available in the prompt. See [Webhook signature](#webhook-signature) below for the exact header and format.

By default, each fire starts a fresh session on a new branch. A trigger can instead reuse or pin a session, and a webhook trigger can filter which payloads start one — see [Session strategy](#session-strategy) and [Payload templating](#payload-templating) below.

## App event triggers

An event trigger runs an agent when an app event happens: a pull request opens, an email arrives, an issue changes state. You do not wire a webhook, a secret, or a signature. Beeuni subscribes to the event for you and signs nothing by hand.

> **Info**
>
> App event triggers are a **beta** feature behind the project flag `event_triggers`. It is off until you turn it on. See [Turn on app event triggers](#turn-on-app-event-triggers).

An event comes from an **event source**: an adapter that delivers the events of an app. Event IDs such as `GITHUB_PULL_REQUEST_CREATED` identify the events supported by that source. Use the event catalog to select an event for your connector.

### Turn on app event triggers

The project flag `event_triggers` ("App event triggers") gates the whole feature. It is off by default.

- **Web app:** open **Settings**, then **Feature flags**, and switch on **App event triggers**.
- **CLI:** `beeuni projects features enable event_triggers`. `beeuni projects features disable event_triggers` turns it off.

With the flag off:

- `beeuni triggers events …` and `beeuni triggers add|set --type event` print `App event triggers are off for this project. Turn them on: beeuni projects features enable event_triggers` and exit with code 1. The API answers `403` with `code: feature_disabled`.
- A `type: event` trigger in `beeuni.yaml` is kept, but it is not subscribed. Its status reads `error`: `App event triggers are off for this project. Turn them on in Settings → Feature flags.` The other triggers in the file still apply.
- Cron, webhook and monitor triggers are not affected.

Turning the flag on subscribes the event triggers already in `beeuni.yaml`; you do not save them again. Turning it off removes every event subscription of the project, so no event starts a session.

The flag appears in **Feature flags** only on a deployment with an event source configured.

### Set up in the web app

1. Open the project, then **Triggers**, **New trigger** and **Set up manually**. One window shows the whole trigger: **When**, **Then**, the name and the options. The line under the title sums up the trigger as you set it.
2. Under **When**, choose **App event**.
3. Browse the apps. Your project's apps come first, then every app with events. Pick one to see its events. You do not need a connector for this: nothing is created while you browse.
4. Pick an event. The form for the event's settings, such as the repository, appears below it. Under **Connection**, pick a connector and one of its shared accounts, or connect another account. The account marked **Default** is used unless you pick another. If your project has no connector for the app, Beeuni adds one when you create the trigger.
5. Under **Then**, write the instruction, and pick the agent and the model. The name follows the event until you type your own. **Options** holds the run location, who can open the sessions, conditions, a custom ID, and starting paused.
6. Choose **Create trigger**. A missing or wrong value shows under its field, and nothing is created until it is fixed.

The trigger is live as soon as its badge reads **Live**. The other badges are **Activating**, **Needs connection**, and **Error**. They match the `pending`, `needs_connection`, and `error` statuses below.

### See every app event trigger

In the web app, open **Triggers** and choose **App events**. The filter row reads **All**, **Schedules**, **App events**, and **Webhooks**, each with a count. The choice is kept in the address as `?type=event`, so you can link to it. The rows are grouped by app. Below them, **Apps with events** lists every app that can start an agent, the apps your project already uses first. Choose one to open the new trigger window on that app's events.

A connector's window has a **Triggers** tab with the app events on that connector. The tab appears only for an app that has events.

With the CLI:

```bash
beeuni triggers ls --type event                 # every app event trigger, grouped by app
beeuni triggers ls --connector github-work      # the app events on one connector
beeuni triggers ls --type event --json          # the same list as JSON
```

`--type` also takes `cron`, `webhook`, and `monitor`. `--connector` and `--type` combine.

### Set up with the CLI

An agent or a person runs these steps in order. Each step prints what the next step needs.

```sh
beeuni triggers events --apps
beeuni triggers events --app github
beeuni connectors add github --provider composio --app github --apply
beeuni connectors connect github --owner project
beeuni triggers events --connector github
beeuni triggers events --connector github --event GITHUB_PULL_REQUEST_CREATED
beeuni triggers add pr-review --type event \
  --connector github --event GITHUB_PULL_REQUEST_CREATED \
  --config repo=hekmat/api \
  --prompt "Review {{ event.data.html_url }}" --apply
beeuni triggers info pr-review
```

1. `triggers events --apps` lists the apps that can trigger events. Apps are grouped under their event source (`source: composio`). Each app lists its connectors (profiles) and their shared accounts. The `STATE` column reads `connected` or `needs account`; apps with no connector appear under `No connector yet`.
   `triggers events --app <app>` lists one app's events, and `--event <TYPE>` one event's settings, before you add any connector.
2. `connectors add` adds the connector when the app has none. Skip it when `CONNECTOR` is set.
3. `connectors connect <slug> --owner project` prints a link. A person opens it to connect the shared account. A member's private account does not count. Run it again to add a second shared account, then label it with `connectors rename <id> <label>`.
4. `triggers events --connector <slug>` lists the events. Add `--event <TYPE>` for one event's settings and its `{{ event.data.* }}` variables. Read each setting's description: some events take a full name in one setting, such as `repo: hekmat/api`, and reject the same value split across `owner` and `repo`.
5. `triggers add --apply` creates the trigger now and prints its status and the next step. A missing or invalid setting exits with code 2 and lists every problem. Without `--apply`, the CLI writes the block to the local `beeuni.yaml`; run `beeuni ship` to apply it.
6. `triggers info <slug>` shows the status. Repeat until it reads `live`.

`--source <adapter>` sets the event source and is optional: without it the trigger uses the connector's provider. The connector must belong to that source. A `composio` source on a connector of another provider reads `error`: `Connector "<slug>" is a <provider> connector; source "composio" needs a composio connector.` An unknown source is rejected with `Unknown event source "<x>". Sources: composio.` `beeuni triggers set <slug> --source <adapter>` changes it; changing `--connector` clears it.

`--config key=value` repeats once per setting and is converted to the setting's type. `--config-json '{"limit": 5}'` passes typed values. `beeuni triggers set <slug> --config key=value` merges into the live config; `--config-json` replaces it. Do not pass both. An event trigger takes none of `--cron`, `--run-at`, `--timezone`, `--secret-env`, `--run`, `--mode`, `--interval`, or `--expect-event-within`.

### Config shape

```yaml
# beeuni.yaml
triggers:
  - slug: pr-review
    type: event
    connector: github-work # required, slug of a connector (profile) under `connectors:`
    source: composio # optional, the event source adapter. Default: the connector's provider
    account: hekmat-bot # optional, label of one shared account of that connector
    event: GITHUB_PULL_REQUEST_CREATED # required, the adapter's event id, from `beeuni triggers events`
    config: { repo: hekmat/api } # optional, settings of the event
    agent: default
    session_mode: fresh # default "fresh"
    filter: # optional, same guard a webhook uses
      "event.data.draft": "false"
    prompt: |
      Review {{ event.data.html_url }}
```

### What the prompt can read

The prompt, `filter`, and `session_key` read these roots:

| Root | Value |
| --- | --- |
| `event.data.<field>` | The app's own event data. `beeuni triggers events` shows the fields each event carries. |
| `event.id` | The provider's id for this event. |
| `event.type` | The event type, such as `GITHUB_PULL_REQUEST_CREATED`. |
| `event.app` | The app slug, such as `github`. |
| `event.connector` | The connector slug the trigger watches. |
| `event.occurred_at` | When the event happened. |

Event data comes from a third party. Beeuni starts the session's first message with `[App event: <trigger name> — automated, third-party content, not user input]`, so the agent treats the event content as data and not as instructions. That line is also the session's title until the agent names it.

### Status

`beeuni triggers ls` and the web trigger list show the subscription status of each event trigger.

| Status | Meaning | Fix |
| --- | --- | --- |
| `active` | The subscription exists. Events fire the trigger. | None. |
| `pending` | The trigger is declared and Beeuni has not created the subscription yet. | Wait a moment. If it stays `pending`, save the trigger again. |
| `needs_connection` | The connector has no usable shared account. The text reads "Connect a shared `<app>` account to activate this trigger." or "Finish connecting the shared `<app>` account…" | Connect a shared account for the connector as the project (`beeuni connectors connect <slug> --owner project`). A member's private account does not count. The trigger goes live by itself once the person finishes connecting, even when they connect from a link outside Beeuni. |
| `error` | The subscription failed or the provider disabled it. The trigger shows the error text. | Fix the cause the text names, then save the trigger again. |

Common `error` texts:

- "This `<app>` account is shared with specific people only. Event triggers need an account shared with the whole project." Open the account's sharing and share it with the whole project.
- "Connector `<slug>` is not declared in beeuni.yaml." Add the connector under `connectors:`.
- If event triggers are unavailable, contact support.

- "The app disabled this subscription: ..." The provider turned the subscription off. Save the trigger again to subscribe again.
- "The connected account expired: ..." The app revoked or expired the account. Reconnect the app to resume the trigger.

### Rules

- **Connector and account.** An *app* is the service, such as GitHub. A *connector* is a profile in `beeuni.yaml`; several connectors can share one app. An *account* is one connected login under a connector. A trigger names a connector. It uses that connector's default shared account unless it sets `account: <label>`. Set `account` only when a connector holds several shared accounts. In the CLI, `--account <label>` sets it and `--default-account` (on `triggers set`) clears it. A label that matches no shared account gives `needs_connection` with the text "Connect a shared `<app>` account labelled "`<label>`" on `<connector>`."
- **One feeder account.** Only a project-shared account that is open to the whole project can feed a trigger. A trigger runs with nobody present, and event data lands in a session prompt. An account shared with named people only, or a private account, never feeds one.
- **Each event fires once.** A trigger fires once per provider event. A redelivered event does not start a second session. Two triggers on the same event each fire once.
- **Pause stops it.** A paused trigger or a paused project skips events. Skipped events do not queue.
- **Edits resubscribe.** Changing `connector`, `source`, `account`, `event`, or `config` replaces the subscription. Deleting the trigger removes it.

## Monitors

**Experimental.** Monitors run only where the `monitors` feature flag is on. The flag is off by default. While it is off, the platform provisions no monitor box and fires no monitor event.

A monitor watches something that neither pushes webhooks nor fits a schedule: a live log, a queue depth, a page that changes, a price. Beeuni runs your command 24/7 in the project's monitor box — one persistent microVM per project, with the same isolation boundary and the same project secrets a session sandbox gets. Deterministic code watches; the agent wakes only when the command emits a line.

Four rules define the contract:

- **Stdout lines are events. Nothing else is.** Stderr is diagnostics: visible in the monitor's logs, never fires.
- Each line fires the trigger exactly once, through the path a webhook already uses: `filter` → prompt template → `session_mode`.
- **A monitor cannot fail silently.** Process exit, restart-budget exhaustion, and silence longer than `expect_event_within` each fire a platform-written lifecycle event in the same stream.
- `session_mode` defaults to `reuse` on a monitor, not `fresh`. A monitor fires repeatedly by design, so `fresh` would mint one session per event.

1. **Add the monitor**

    ```sh
    beeuni triggers add checkout-errors --type monitor \
      --run "./monitors/checkout-errors.ts" \
      --mode poll --interval 60s --expect-event-within 24h \
      --prompt "Checkout monitor emitted: {{ line }}"
    ```

    `--mode poll` re-runs the command every `--interval` and expects it to exit. `--mode stream` runs it once and keeps it alive; a stream takes no `--interval`. Both shapes produce lines, and nothing downstream can tell them apart. `cron`, `run_at`, `timezone`, and `secret_env` are rejected on a monitor.

2. **Ship it**

    ```sh
    beeuni ship
    ```

    The platform starts the monitor box once this lands on the default branch.

3. **Confirm it runs**

    ```sh
    beeuni triggers ls
    beeuni triggers info checkout-errors
    ```

    `ls` shows a monitor's mode and interval where a cron shows its schedule. `info` shows `run`, `mode`, `interval`, and `expect_event_within`.

### Monitor limits

Every bound below is enforced by the platform.

| Bound | Value |
| --- | --- |
| Monitors per project | 10 enabled |
| Poll interval | at least 30s |
| `expect_event_within` | at least 5m |
| Event rate per monitor | 60/hour sustained, burst 30. Overflow suppresses the monitor for 10 minutes; 3 suppressions in 24 hours disables it. |
| Line length | 8 KiB, truncated with a `truncated: true` marker |
| Restart budget | 5 restarts / 10 minutes, then a `restart_budget_exhausted` lifecycle event and 15-minute backoff |
| Event retention | 30 days |
| Monthly box budget | $75 by default. Past it, the box stops and one `budget_exceeded` lifecycle event fires. |

The monitor box needs a provider that supports a persistent sandbox. Where the project's provider cannot hold one, the `monitors` flag reports itself unavailable.

## Config shape

```yaml
# beeuni.yaml
triggers:
  - slug: daily-digest # required, lowercase + dashes, unique per project
    name: Daily digest # optional, defaults to slug
    type: cron # "cron" | "webhook" | "event" | "monitor", required
    agent: beeuni # optional, defaults to "default"
    model: anthropic/claude-sonnet-4-5 # optional, resolves at fire time if unset
    enabled: true # optional, default true
    cron: "0 0 9 * * 1-5" # 6-field expression, mutually exclusive with run_at
    timezone: America/Los_Angeles # IANA name, default UTC
    session_mode: reuse # "fresh" | "reuse" | "pinned" | "keyed", default "fresh"
    filter: # optional, webhook payload guard
      "body.data.direction": "inbound"
    prompt: "Summarize {{ body.text }}" # required, template string
```

A monitor replaces the schedule fields with its command and shape:

```yaml
# beeuni.yaml
triggers:
  - slug: checkout-errors
    type: monitor
    run: ./monitors/checkout-errors.ts # required, repo-relative command
    mode: poll # "poll" | "stream", required
    interval: 60s # required on poll, invalid on stream
    expect_event_within: 24h # optional silence watchdog
    agent: oncall
    session_mode: reuse # the monitor default
    filter: # optional, same guard a webhook uses
      "line.severity": "error"
    prompt: "Checkout monitor emitted: {{ line }}"
```

An event trigger replaces the schedule fields with the app and event it watches. See [App event triggers](#app-event-triggers).

Legacy `beeuni.toml` uses the same fields in a different container; see [legacy TOML](/docs/project/legacy-toml).

## Fields

| Field | Required | Default | Notes |
| --- | --- | --- | --- |
| `slug` | yes | — | `[a-z0-9][a-z0-9_-]{0,127}`, unique per project. |
| `type` | yes | — | `cron`, `webhook`, or `monitor`. |
| `prompt` | yes | — | Template string. Renders as the session's first message. |
| `name` | no | `slug` | Human label. |
| `agent` | no | `default_agent` | Must name a key in `agents:`. Omit it to use `default_agent`; do not write the literal `default`. |
| `model` | no | resolves at fire time | Wire form `provider/model`, for example `anthropic/claude-sonnet-4-5`. |
| `enabled` | no | `true` | When `false`, the scheduler and the webhook receiver skip the entry. |
| `session_mode` | no | `fresh`, or `reuse` on a monitor | See [Session strategy](#session-strategy). |
| `session_id` | required for `pinned` | — | Exact session to re-prompt. |
| `session_key` | required for `keyed` | — | Template string. Setting it alone implies `session_mode: keyed`. |
| `filter` | no | — | Dotted path → expected string. A webhook delivery that does not match returns `200` and fires no session. |
| `cron` | one of `cron`/`run_at`, on `type: cron` | — | 6-field expression: second minute hour day month weekday. Fires at most once every 60 seconds; a faster expression fails `beeuni validate` and the API. |
| `run_at` | one of `cron`/`run_at`, on `type: cron` | — | ISO-8601 timestamp. Fires once, then stays dormant. |
| `timezone` | no, cron only | `UTC` | IANA name. |
| `secret_env` | required, webhook only | — | Name of a project secret holding the signing key. The secret must use `broker` delivery with the `connector` consumer. |
| `run` | required, monitor only | — | Repo-relative command whose stdout lines are the events. One line, at most 1024 characters. |
| `mode` | required, monitor only | — | `poll` re-runs `run` every `interval`; `stream` runs it once and keeps it alive. |
| `interval` | required for `mode: poll` | — | Duration literal (`30s`, `5m`, `24h`, `7d`), minimum `30s`. Invalid on `mode: stream`. |
| `expect_event_within` | no, monitor only | — | Duration literal, minimum `5m`. Silence longer than this fires a lifecycle event. |

A cron trigger needs `cron` or `run_at`, never both. A webhook trigger without `secret_env` is rejected — there is no unauthenticated webhook. A monitor needs `run` and `mode`, and rejects `cron`, `run_at`, `timezone`, and `secret_env` outright — a manifest that claims a schedule the monitor runner never reads is a lie.

Configure the signing secret before you create or update a webhook trigger:

```bash
beeuni secrets set WEBHOOK_SECRET=-
beeuni secrets delivery WEBHOOK_SECRET broker --consumer connector
```

Pass the value to the first command on standard input. If the secret previously
used sandbox delivery, rotate it after the delivery change because an existing
sandbox can retain the previous value.

## Session strategy

`session_mode` controls which session a fire re-prompts. Beeuni tries the modes below in order and falls through on failure at each step.

1. **`pinned`** — re-prompt the exact `session_id`. If that session is gone or failed, fall through.
2. **`keyed`** — render `session_key` against the payload, then look up the most recent non-failed session previously stamped with that exact key. If the key renders empty, or no session matches, fall through to a fresh session. It never falls through to another key's session.
3. **`reuse`** — re-prompt the most recent non-failed session this trigger previously created. A pinned trigger falls back here too, before falling further.
4. **`fresh`** — create a new sandbox and branch. This is the default, and the final fallback for every mode. The new session becomes the trigger's session for future `reuse` and `keyed` fires.

A `reuse` or `keyed` session whose history no longer fits the model, even after automatic compaction, is retired. The next fire starts a fresh session. Any other failure keeps the session for the next fire.

A frequent cron trigger that should not re-send an ever-growing history can roll its session over on a calendar boundary: `session_key: "merge-{{ cron.scheduled_date }}"` keeps one serialized session per UTC day, and `{{ cron.scheduled_hour }}` one per UTC hour.

## Failed runs

When a run that a trigger started ends with an error, Beeuni records it on the trigger:

- `last_status` becomes `failed` and `last_error` holds the reason, for example `Out of credits: Payment Required: Insufficient credits.`
- The Schedule page marks the trigger "Last run didn’t finish" and shows the reason in its panel.
- With [alerts](#alerts) on, the trigger's alert recipients get one alert when the first run fails. A later failed run updates the reason without another alert.
- The trigger stays `failed` while it keeps firing. A new fire does not prove that its run works.

The next run that finishes clears the failure and, with alerts on, sends one recovery alert. A run you stop yourself, a subagent, and a retried model call are not run outcomes. A fire that fails before a run starts, for example a session that cannot be created, clears on the next fire that succeeds.

## Alerts

An alert is a [notification](/docs/work/notifications) about a trigger that stops working. Beeuni sends one `automation_failed` alert when a trigger starts failing, and one `automation_recovered` alert when it works again. Failures in between send nothing new. An alert names the trigger, carries the error text, and opens the project's **Triggers** page. A reminder's alert opens the **Reminders** page.

Alerts are on when the project's `notification_center` flag is on. See [Turn notifications on](/docs/work/notifications#turn-notifications-on). With the flag off, Beeuni still records each failure on the trigger, but sends no alert. An owner of the account gets one mobile push when a trigger's runs start failing. A failed fire and a recovery send nothing. A person who writes a trigger while the flag is off is not recorded as its writer. Once the flag is on, that trigger follows the rule for a trigger with no recorded writer, below, until someone edits it.

### Who gets alerts

- **The people who wrote the trigger.** That is the person who created it, and each person who edited it later, through the web app, the API, the SDK, or the CLI (`beeuni triggers add --apply`, `beeuni triggers set`). An agent that creates or edits a trigger with its session token counts as the person it acts for. An API key and a service account count as nobody.
- **The project managers**, when none of those people can still read the project's triggers: the account owners, the account admins, and the people assigned the project's **Manager** role directly, not through a group.

A reminder alert goes only to people who can open the reminder's session. The person who set the reminder gets it. When that person can no longer get it, the project managers who can open the session get it. When the session is deleted, only the person who set the reminder can get it.

A trigger with no recorded writer alerts the project managers. That is a trigger added by editing `beeuni.yaml` and running `beeuni ship`, or a trigger created before alerts existed. The next person who edits it through the API becomes a recipient. Deleting a trigger deletes its recipients.

### When an alert fires

Beeuni tracks two kinds of failure. A trigger has at most one open alert. Only a success of the same kind ends it.

| Failure | Starts the alert | Sends the recovery alert |
| --- | --- | --- |
| A fire fails | A fire that fails for good, for example a session that cannot be created because the account is out of credits. | The next fire that reaches a session. A fire that queues its prompt or its session create ends the alert when the queued work is delivered. |
| A run fails | A session the trigger started ends its turn with an error. See [Failed runs](#failed-runs). | The next run that finishes. |

Beeuni retries a cron fire, a reminder, and a monitor event before it gives up. It alerts when it gives up, never on a retried attempt. A session create that Beeuni puts back in its queue alerts only when its retries run out.

A manual or webhook fire alerts on its first failure, because Beeuni cannot know whether the sender retries. An app event fire alerts on its first failure only when a retry cannot help, for example when the account is out of credits. For a failure that a retry can fix, the app sends the event again, and Beeuni alerts when the third delivery of the same event fails.

Beeuni emails the `automation_failed` alert at once, when the deployment can send email. The `automation_recovered` alert gets a push only, unless you turn its **Email** switch on.

### Stop alerts

Open **Settings → Notifications** and turn off **Push** and **Email** for the two automation kinds, failed and recovered. This applies to every trigger and reminder. The bell still lists each alert. You cannot turn alerts off for one trigger only.

## Session access

Sessions a trigger creates use the private policy by default. The trigger
agent's service account owns them — an agent's identity is a `service_account`
principal, so it can own a session and hold assignments like any other
principal. A project manager can always open them. An account owner and an
account admin hold manager-equivalent access on every project, so the same
applies to them. The person who configured or manually fired the trigger does
not gain access through that action unless they hold one of those roles.

Trigger settings offer three policies:

- **Trigger agent and project managers** — no ordinary project member can open the session.
- **Selected teammates** — the trigger agent, project managers, and selected project members or account groups.
- **Whole project** — every project member.

This policy is a per-resource visibility setting on top of the role model, not a
role. It decides who can open one trigger's sessions. It grants no permission the
role verdict denies. See
[Accounts & access](/docs/accounts#per-feature-access-settings).

The access policy is account-local runtime state. It does not enter the
portable `beeuni.yaml` manifest because principal ids belong to one
account. Use the dashboard to configure it.
Updating only this policy creates no Git commit. Saving a policy also updates
prior sessions created by that trigger.

A pinned session keeps its own sharing settings because the trigger did not
create it. If a pinned session is unavailable and the trigger creates a
fallback session, the trigger policy applies to that new session.

## Payload templating

`prompt` and `session_key` render with the same engine: `{{ token.dotted.path }}`. A missing value renders as an empty string — no error, no leftover `{{ }}`. Objects and arrays render as JSON. `session_key` is trimmed and truncated to 512 characters.

Every fire also gets `{{ trigger.slug }}`, `{{ trigger.type }}`, and `{{ trigger.kind }}` (always `git`). The rest of the variable set depends on how the trigger fired.

| Source | Variables |
| --- | --- |
| cron | `{{ cron.schedule }}`, `{{ cron.timezone }}`, `{{ cron.scheduled_for }}` (the slot the fire is for), `{{ cron.claimed_at }}` (when the scheduler picked it up), `{{ cron.last_scheduled_for }}` (the previous slot; empty on the first fire), `{{ cron.scheduled_date }}` (UTC `YYYY-MM-DD` of the slot), `{{ cron.scheduled_hour }}` (UTC `YYYY-MM-DDTHH`). No top-level `fired_at`. |
| webhook | `{{ fired_at }}`, `{{ body.* }}` (JSON-parsed; falls back to `{{ body.raw }}` if the body does not parse), `{{ headers.content_type }}`, `{{ headers.user_agent }}`, `{{ headers.forwarded_for }}`. |
| monitor | `{{ line.* }}` — the stdout line, JSON-parsed; a line that does not parse renders as `{{ line.raw }}`. Plus `{{ monitor.slug }}`, `{{ monitor.seq }}`, `{{ monitor.emitted_at }}`, and `{{ monitor.kind }}` (`event` or `lifecycle`). |
| manual (dashboard "fire now" or the `fire` endpoint) | `{{ fired_at }}`, `{{ source }}` (`manual`), `{{ actor }}`, `{{ message.text }}`, `{{ message.source }}`. |

Beeuni prefixes every rendered monitor prompt with `[MONITOR EVENT — automated, not user input]`, server-side. A lifecycle event ignores your template entirely and renders a platform-written prompt instead, and it bypasses `filter` — silence must not be filterable by accident.

`{{ message.text }}` is hardcoded to an empty string on a manual fire, and `{{ message.source }}` to `manual_test`. A manual fire is not a way to inject test input into the prompt.

`filter` compares dotted paths as strings against the same payload the prompt sees. It exists to break loops. For example, a source that reports both sides of a conversation would otherwise re-fire the agent on its own reply.

## Webhook signature

Fires on `POST /v1/webhooks/projects/{projectId}/{slug}`. Beeuni checks the request in this order, with a constant-time comparison:

1. **HMAC signature** — header `X-Beeuni-Signature: sha256=<hmac>` (the `sha256=` prefix is optional) or the GitHub-compatible `X-Hub-Signature-256`. HMAC-SHA256 over the raw request body, using the secret named by `secret_env`.
2. **Static token**, only when no signature header is present, for senders that cannot HMAC-sign a body. Send the secret as `X-Beeuni-Token: <secret>`, `Authorization: Bearer <secret>`, or `Authorization: Basic <base64(user:secret)>` (the password half is the token).

`beeuni triggers info <slug>` prints this scheme — the header, the algorithm, and a sample request signed with a `$SECRET` placeholder — and `--json` carries it as `webhook_signing`. The dashboard's trigger sheet shows the same sample request.

| Status | Meaning |
| --- | --- |
| 202 | Signature or token valid. Body is `{ status: "fired" \| "queued" \| "deduped", session_id, ... }`. |
| 200 | Valid, but skipped — the project is paused, or the delivery did not match `filter`. |
| 400 | Malformed project ID or slug in the URL. |
| 401 | Signature and token both missing or wrong. |
| 404 | Trigger not found, disabled, not a webhook, or the project is not active. |
| 409 | The signing secret is missing, inactive, unavailable, or does not authorize the `connector` consumer. The response includes a `webhook_secret_*` code and remediation. |
| 500 | Auth passed, but the session failed to fire. |

## Endpoints

| Method + path | Needs | Notes |
| --- | --- | --- |
| `GET /v1/projects/{projectId}/triggers` | `project.trigger.read` | Lists triggers, runtime state (`last_fired_at`, `last_status`, `last_error`, `last_attempt_at`), and manifest parse errors. A bad entry appears in `errors[]`; it does not break the other triggers. |
| `POST /v1/projects/{projectId}/triggers` | `project.trigger.create` | Creates a trigger. Commits to the manifest directly. |
| `PATCH /v1/projects/{projectId}/triggers/{slug}` | `project.trigger.update` | Partial update, merged onto the current entry. |
| `DELETE /v1/projects/{projectId}/triggers/{slug}` | `project.trigger.delete` | Also clears the trigger's runtime state and its [alert recipients](#alerts). |
| `PATCH /v1/projects/{projectId}/triggers/activation` | `project.trigger.update` | Body `{ paused: boolean }`. See [Pause and resume](#pause-and-resume). |
| `POST /v1/projects/{projectId}/triggers/{slug}/fire` | `project.trigger.fire` | Manual fire. The built-in project `member` role holds `project.trigger.fire`, so an ordinary member can fire a trigger. |
| `POST /v1/webhooks/projects/{projectId}/{slug}` | signature or token | Public URL, gated by the webhook secret. |

## Pause and resume

A project-level switch stops every trigger in the project at once, independent of each trigger's own `enabled` field. While paused, the scheduler skips the project and inbound webhooks return `200` with `{ status: "skipped" }` — no session fires. A manual fire still works. Use this when the same repository runs on two control planes (for example, dev and production) so cron does not fire twice. CLI: `beeuni triggers pause` and `beeuni triggers resume`. See [CLI](/docs/cli) for the full `beeuni triggers` command group.

## Limits and reliability

- The scheduler polls roughly every second (default 1,000 ms; configurable via `BEEUNI_TRIGGER_SCHEDULER_INTERVAL_MS`). A cron run does not start exactly on its slot: each trigger gets a fixed offset of up to a quarter of its period, at most 30 minutes, so many triggers on the same minute do not start at once. The trigger's detail and `GET /triggers` (`next_fire_at`) show the time the next run starts.
- A fire that fails sets the trigger's last status to failed, with the error. This holds for every source: cron, manual, webhook, app event and monitor. The next fire that succeeds clears a failed fire. A failed run clears as [Failed runs](#failed-runs) describes.
- Each project allows 3 triggered sessions provisioning at once, by default. A fire past that returns `queued` (`202`) instead of failing, and runs once one of them finishes provisioning. There is no cap on how many sessions run at once.
- A manual or webhook fire has a 45-second timeout. Loading the manifest has a 30-second timeout.
- A cron fire is keyed on the due schedule slot, so a fire that timed out but actually landed does not duplicate on retry.
- A webhook delivery runs once per event. Beeuni reads the event ID from `X-Beeuni-Delivery-Id`, `X-GitHub-Delivery`, `X-Gitlab-Event-UUID`, `Linear-Delivery`, `webhook-id` or `svix-id`, in that order; a retry with the same ID answers `202 { status: "deduped" }` and runs nothing. `X-Request-Id` is not an event ID. Without an event ID, an identical body and signature within 10 minutes counts as the same event; after that it runs again, so a job that posts the same static body on every deploy runs on every deploy. Send `X-Beeuni-Delivery-Id` with a new value per event (the sample request uses `$(uuidgen)`).
- A delivery whose earlier attempt failed for good (for example, the account was out of credits) or whose session was deleted runs again when redelivered with the same ID. A duplicate does not change `last_fired_at`.
