# Alerts an agent can write

A dashboard tells you something when you open it. **An alert tells you when you were not looking** — and that is the part a harness cannot produce on its own: it needs hosted state, fresh data, a wall clock, and somewhere to deliver.

An alert is five decisions, so the API is five fields:

| Field     | What it decides                                             |
| --------- | ----------------------------------------------------------- |
| `when`    | The SQL whose **rows are the breach**                       |
| `say`     | The sentence the recipient reads, filled from the first row |
| `every`   | How often the condition is looked at                        |
| `capture` | What picture is taken, in Playwright                        |
| `to`      | Who receives it                                             |

## The worked example[​](#the-worked-example "Direct link to The worked example")

```
from vibedasher import Vibedasher



client = Vibedasher()



alert = client.alerts.create(

    viz_id=1430,

    name="Margin watch",

    when="""SELECT region, ROUND(margin*100,1) AS margin_pct

            FROM margins WHERE margin < 0.10 ORDER BY margin LIMIT 5""",

    say="Profit margin {margin_pct}% is below 10% in {region}",

    every="0 7 * * *",

    capture="""await page.goto('?f.region={region}');

               await expect(page.getByTestId('chart-rendered')).toBeVisible();

               await page.locator('[data-capture-root]').screenshot();""",

    to=["finance@acme.com"],

)



client.alerts.verify(alert.id)   # every selector resolves — captures nothing, sends nothing

client.alerts.test(alert.id)     # captures for real AND MAILS finance@acme.com
```

The same call in TypeScript, where the SDK is camelCase:

```
const alert = await client.alerts.create({

  vizId: 1430,

  name: 'Margin watch',

  when: 'SELECT region, ROUND(margin*100,1) AS margin_pct FROM margins WHERE margin < 0.10 ORDER BY margin LIMIT 5',

  say: 'Profit margin {margin_pct}% is below 10% in {region}',

  every: '0 7 * * *',

  capture: "await page.goto('?f.region={region}');\nawait expect(page.getByTestId('chart-rendered')).toBeVisible();\nawait page.locator('[data-capture-root]').screenshot();",

  to: ['finance@acme.com'],

});



await client.alerts.verify(alert.id); // every selector resolves — captures nothing, sends nothing

await client.alerts.test(alert.id);   // captures for real AND MAILS finance@acme.com
```

Over MCP, it is `create_alert` with the same five names, then `verify_alert` and `test_alert`.

## The truthy contract[​](#the-truthy-contract "Direct link to The truthy contract")

**Zero rows means not fired. One or more rows means fired.** There is no comparator to get backwards and no threshold to keep in sync with the query: the `WHERE` clause *is* the condition.

The first returned row is the message's metadata, and `say` turns it into words. Write the breach and its explanation as one query, so the sentence cannot claim anything the data does not:

```
SELECT region,

       ROUND(margin*100,1) AS margin_pct,

       10                  AS threshold_pct

FROM margins

WHERE margin < 0.10

ORDER BY margin
```

```
Profit margin 8.2% is below 10% in West, and 3 more
```

Extra rows are context, capped at five. A `say` placeholder naming a column the query does not `SELECT` is **refused when you create the alert**, with the column named and the available ones listed — never a blank, and never the literal `{region}` mailed to somebody as if it were text you wrote. Aliases count, so `ROUND(margin*100,1) AS margin_pct` is what makes `{margin_pct}` resolve.

The older shape — `comparator` plus `threshold` over a single numeric cell — is still accepted as sugar. Prefer the truthy form: "zero rows" is unambiguous in a way "the number is not below the threshold" is not.

## The picture shows the breach, not the default view[​](#the-picture-shows-the-breach-not-the-default-view "Direct link to The picture shows the breach, not the default view")

`{column}` works in `capture` too, and this is the point of it. A placeholder there is a **value**: `?f.region={region}` pins the filter to the region that actually fired, so the alert about West is a picture of West. The link in the message is built from the same recipe, so the picture and the link agree.

```
capture: await page.goto('?page=overview&f.region={region}');

         await expect(page.getByTestId('chart-rendered')).toBeVisible();

         await page.locator('[data-capture-root]').screenshot();
```

`getByTestId('<name>')` addresses a control by **the config name you gave it** — `describe_dashboard_controls(viz_id)` over MCP, or `GET /v1/viz/{id}/controls`, lists the names and values a dashboard actually accepts. Never a raw CSS path: those are refused, because they break silently the next time the markup moves.

The recipe is **parsed, never executed**. `page.evaluate`, `require`/`import`, template literals, string concatenation, comments and CSS paths each come back as a refusal that names the line. What runs is a small declarative recipe the capture lane applies by building the URL, which is deterministic in a way a click is not.

## `every`: a cron string or a duration[​](#every-a-cron-string-or-a-duration "Direct link to every-a-cron-string-or-a-duration")

```
every="0 7 * * *"   # 07:00 every day

every="15m"         # every fifteen minutes

every="2h"          # every two hours

every="1d"          # once a day at midnight
```

Only steps that tile their unit evenly are accepted. `7m` is refused: cron restarts a step at the top of the hour, so `*/7` would fire at :00, :07 … :56 and then again four minutes later — not "every seven minutes". The refusal lists the steps that do work.

A cron expression that is valid and can **never occur** — `0 0 30 2 *`, asking for February 30th — is refused too. Nothing downstream would ever complain about one: the alert would sit `active`, never due, never evaluated, and every surface would report it as healthy forever.

Omit `every` with `when` set and the alert is evaluated on the scheduler's own cadence.

## `to`: the channel is read off the address[​](#to-the-channel-is-read-off-the-address "Direct link to to-the-channel-is-read-off-the-address")

```
to=[

    "finance@acme.com",                       # email

    "https://hooks.slack.com/services/T/B/X", # Slack incoming webhook

    "usr_1a2b3c",                             # a user id — in-app notification

]
```

The three shapes are disjoint, so there is no channel to declare beside the address. Something that looks like an address and is neither is refused rather than filed as a user id that does not exist.

## The cache-zero guarantee[​](#the-cache-zero-guarantee "Direct link to The cache-zero guarantee")

**A condition is always run with the SQL cache bypassed.** You do not ask for it and you cannot turn it off. An alert that fired on a cached result would not be an alert — it would be a report of something that was true at some point. No cache read, no hit-count bump, no cache write: the query goes to the engine every time it is evaluated.

## Fire on the transition, not on every check[​](#fire-on-the-transition-not-on-every-check "Direct link to Fire on the transition, not on every check")

Entering the breach fires once. Staying in it is deliberately quiet. The alert re-arms when the condition clears. A metric that flaps around its limit sends one message, not one per tick.

## The canary: run `verify` after you change a dashboard[​](#the-canary-run-verify-after-you-change-a-dashboard "Direct link to the-canary-run-verify-after-you-change-a-dashboard")

`verify` is the one call that tells you your recipe still resolves.

```
result = client.alerts.verify(alert.id)

if not result.verification.resolved:

    for step in result.verification.steps:

        print(step.index, step.status, step.detail)
```

It **captures nothing, sends nothing, writes no row and moves no column.** It runs the same executor a real capture runs, in resolve-only mode, against the dashboard's own generated code — so it cannot drift from what the capture will do. Every step is reported, not just the first: rename three selectors and you are told about three.

Make it a habit, because the failure it catches is invisible otherwise:

> **You edit a dashboard. You rename a filter. The alert's recipe still points at the old name. Nothing fails — until a recipient opens an email with a picture of the wrong thing in it.**

A red step **is** the breaking change you just made. Run `verify` after every dashboard edit that touches a page id, a selector name or a filter value, and you find it while you can still fix it.

## Every refusal names the fix[​](#every-refusal-names-the-fix "Direct link to Every refusal names the fix")

A `400` from these endpoints is meant to be read and acted on, not retried:

```
say= references {margin_pct}, but the when= query does not return that column.

It returns: region. Fix: SELECT it (an alias counts, e.g.

'ROUND(margin*100,1) AS margin_pct'), or reference one of the columns above.
```

```
every='7m' does not divide the hour evenly, so it would not actually run every

7 minutes — cron restarts the step at :00, making the last gap of each hour

shorter. Fix: use one of 5m, 6m, 10m, 12m, 15m, 20m, 30m, or pass the cron

expression you mean.
```

```
Cron '0 0 30 2 *' is a valid expression that never occurs — no date matches its

day-of-month, month and day-of-week together (e.g. '30 2' asks for February

30th). An alert on it would stay 'active' and silent forever. Fix: name a date

that exists, or use a duration like every='1d'.
```

## The order to work in[​](#the-order-to-work-in "Direct link to The order to work in")

1. **`describe_dashboard_controls(viz_id)`** — the names and values this dashboard actually accepts. Skipping it means guessing at selector names.
2. **Write the SQL.** Its rows are the breach; its columns are the sentence.
3. **Write the capture.** Use `{column}` so the picture is the breach.
4. **`verify`** — before you trust it, and again after every dashboard edit.
5. **`test`** — captures for real and returns the delivery row to watch. **It mails the alert's recipients**: a test run is a live send, and only the bookkeeping differs from a real fire (`lastFiredAt` does not move, because nothing fired). Run it when a real message to those addresses is what you want; `verify` is the step that reaches nobody.

## Permissions[​](#permissions "Direct link to Permissions")

Creating, editing, deleting and testing an alert needs the **creator** or **admin** role *and* edit access to the dashboard. Reading one — and `verify`, which derives nothing you could not already read — needs view access. A dashboard you cannot edit answers `404`, not `403`: the refusal does not confirm the id exists.
