# Des alertes qu'un agent peut écrire

Un tableau de bord vous dit quelque chose quand vous l'ouvrez. **Une alerte vous prévient quand vous ne regardiez pas** — et c'est justement la partie qu'un harness ne peut pas produire tout seul : il lui faut un état hébergé, des données fraîches, une horloge murale, et un endroit où livrer.

Une alerte, ce sont cinq décisions, donc l'API a cinq champs :

| Champ     | Ce qu'il décide                                                     |
| --------- | ------------------------------------------------------------------- |
| `when`    | Le SQL dont les **lignes sont l'anomalie**                          |
| `say`     | La phrase que lit le destinataire, remplie depuis la première ligne |
| `every`   | À quelle fréquence la condition est regardée                        |
| `capture` | Quelle image est prise, en Playwright                               |
| `to`      | Qui la reçoit                                                       |

## L'exemple complet[​](#lexemple-complet "Lien direct vers L'exemple complet")

```
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)   # chaque sélecteur se résout — ne capture rien, n'envoie rien

client.alerts.test(alert.id)     # capture pour de vrai ET ENVOIE UN MAIL à finance@acme.com
```

Le même appel en TypeScript, où le SDK est en 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); // chaque sélecteur se résout — ne capture rien, n'envoie rien

await client.alerts.test(alert.id);   // capture pour de vrai ET ENVOIE UN MAIL à finance@acme.com
```

En MCP, c'est `create_alert` avec les cinq mêmes noms, puis `verify_alert` et `test_alert`.

## Le contrat « truthy »[​](#le-contrat--truthy- "Lien direct vers Le contrat « truthy »")

**Zéro ligne signifie non déclenchée. Une ligne ou plus signifie déclenchée.** Il n'y a pas de comparateur à inverser par erreur ni de seuil à garder synchronisé avec la requête : la clause `WHERE` *est* la condition.

La première ligne renvoyée constitue les métadonnées du message, et `say` la transforme en mots. Écrivez l'anomalie et son explication comme une seule requête, pour que la phrase ne puisse rien affirmer que les données ne disent pas :

```
SELECT region,

       ROUND(margin*100,1) AS margin_pct,

       10                  AS threshold_pct

FROM margins

WHERE margin < 0.10

ORDER BY margin
```

Ce que le destinataire lit (la phrase est produite par le programme, elle reste donc en anglais) :

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

Les lignes supplémentaires sont du contexte, plafonnées à cinq. Un espace réservé de `say` qui nomme une colonne que la requête ne `SELECT` pas est **refusé au moment où vous créez l'alerte**, avec la colonne nommée et la liste de celles qui sont disponibles — jamais un blanc, et jamais le littéral `{region}` envoyé par mail à quelqu'un comme s'il s'agissait d'un texte que vous auriez écrit. Les alias comptent : c'est `ROUND(margin*100,1) AS margin_pct` qui fait que `{margin_pct}` se résout.

L'ancienne forme — `comparator` plus `threshold` sur une seule cellule numérique — reste acceptée comme sucre syntaxique. Préférez la forme truthy : « zéro ligne » est sans ambiguïté d'une manière que « le nombre n'est pas sous le seuil » n'est pas.

## L'image montre l'anomalie, pas la vue par défaut[​](#limage-montre-lanomalie-pas-la-vue-par-défaut "Lien direct vers L'image montre l'anomalie, pas la vue par défaut")

`{column}` fonctionne aussi dans `capture`, et c'est tout l'intérêt. Un espace réservé y est une **valeur** : `?f.region={region}` épingle le filtre sur la région qui s'est réellement déclenchée, donc l'alerte sur West est une image de West. Le lien du message est construit à partir de la même recette, donc l'image et le lien concordent.

```
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>')` adresse un contrôle par **le nom de configuration que vous lui avez donné** — `describe_dashboard_controls(viz_id)` en MCP, ou `GET /v1/viz/{id}/controls`, liste les noms et les valeurs qu'un tableau de bord accepte réellement. Jamais un chemin CSS brut : ceux-là sont refusés, parce qu'ils cassent silencieusement dès que le balisage bouge.

La recette est **analysée, jamais exécutée**. `page.evaluate`, `require`/`import`, les littéraux de gabarit, la concaténation de chaînes, les commentaires et les chemins CSS reviennent chacun sous forme d'un refus qui nomme la ligne. Ce qui s'exécute est une petite recette déclarative que la voie de capture applique en construisant l'URL, ce qui est déterministe d'une manière qu'un clic n'est pas.

## `every` : une chaîne cron ou une durée[​](#every--une-chaîne-cron-ou-une-durée "Lien direct vers every--une-chaîne-cron-ou-une-durée")

```
every="0 7 * * *"   # 07h00 tous les jours

every="15m"         # toutes les quinze minutes

every="2h"          # toutes les deux heures

every="1d"          # une fois par jour à minuit
```

Seuls les pas qui pavent leur unité de façon régulière sont acceptés. `7m` est refusé : cron redémarre un pas au début de l'heure, donc `*/7` se déclencherait à :00, :07 … :56 puis de nouveau quatre minutes plus tard — pas « toutes les sept minutes ». Le refus liste les pas qui fonctionnent.

Une expression cron valide mais qui ne peut **jamais survenir** — `0 0 30 2 *`, qui demande le 30 février — est refusée elle aussi. Rien en aval ne s'en plaindrait jamais : l'alerte resterait `active`, jamais due, jamais évaluée, et chaque surface la signalerait comme saine pour toujours.

Omettez `every` avec `when` défini et l'alerte est évaluée à la cadence propre de l'ordonnanceur.

## `to` : le canal se lit sur l'adresse[​](#to--le-canal-se-lit-sur-ladresse "Lien direct vers to--le-canal-se-lit-sur-ladresse")

```
to=[

    "finance@acme.com",                       # e-mail

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

    "usr_1a2b3c",                             # un identifiant d'utilisateur — notification in-app

]
```

Les trois formes sont disjointes, il n'y a donc aucun canal à déclarer à côté de l'adresse. Quelque chose qui ressemble à une adresse sans être ni l'une ni l'autre est refusé plutôt que classé comme un identifiant d'utilisateur qui n'existe pas.

## La garantie cache-zéro[​](#la-garantie-cache-zéro "Lien direct vers La garantie cache-zéro")

**Une condition est toujours exécutée avec le cache SQL contourné.** Vous ne le demandez pas et vous ne pouvez pas le désactiver. Une alerte qui se déclencherait sur un résultat en cache ne serait pas une alerte — ce serait le rapport de quelque chose qui a été vrai à un moment donné. Aucune lecture de cache, aucune incrémentation du compteur de hits, aucune écriture de cache : la requête va au moteur à chaque évaluation.

## Se déclencher sur la transition, pas à chaque vérification[​](#se-déclencher-sur-la-transition-pas-à-chaque-vérification "Lien direct vers Se déclencher sur la transition, pas à chaque vérification")

Entrer dans l'anomalie déclenche une fois. Y rester est délibérément silencieux. L'alerte se réarme quand la condition se résorbe. Une métrique qui oscille autour de sa limite envoie un message, pas un par tick.

## Le canari : lancez `verify` après avoir modifié un tableau de bord[​](#le-canari--lancez-verify-après-avoir-modifié-un-tableau-de-bord "Lien direct vers le-canari--lancez-verify-après-avoir-modifié-un-tableau-de-bord")

`verify` est le seul appel qui vous dit que votre recette se résout encore.

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

if not result.verification.resolved:

    for step in result.verification.steps:

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

Il **ne capture rien, n'envoie rien, n'écrit aucune ligne et ne déplace aucune colonne.** Il exécute le même exécuteur qu'une vraie capture, en mode résolution seule, contre le code généré du tableau de bord lui-même — il ne peut donc pas dériver de ce que fera la capture. Chaque étape est rapportée, pas seulement la première : renommez trois sélecteurs et on vous en signale trois.

Prenez-en l'habitude, car la panne qu'il attrape est invisible autrement :

> **Vous modifiez un tableau de bord. Vous renommez un filtre. La recette de l'alerte pointe toujours vers l'ancien nom. Rien n'échoue — jusqu'à ce qu'un destinataire ouvre un e-mail contenant l'image de la mauvaise chose.**

Une étape rouge **est** le changement cassant que vous venez de faire. Lancez `verify` après chaque modification de tableau de bord qui touche un identifiant de page, un nom de sélecteur ou une valeur de filtre, et vous le trouvez pendant que vous pouvez encore le réparer.

## Chaque refus nomme le correctif[​](#chaque-refus-nomme-le-correctif "Lien direct vers Chaque refus nomme le correctif")

Un `400` renvoyé par ces endpoints est fait pour être lu et suivi d'effet, pas réessayé (les messages ci-dessous sont ceux que l'API renvoie, en anglais) :

```
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'.
```

## L'ordre dans lequel travailler[​](#lordre-dans-lequel-travailler "Lien direct vers L'ordre dans lequel travailler")

1. **`describe_dashboard_controls(viz_id)`** — les noms et les valeurs que ce tableau de bord accepte réellement. L'ignorer, c'est deviner les noms de sélecteurs.
2. **Écrivez le SQL.** Ses lignes sont l'anomalie ; ses colonnes sont la phrase.
3. **Écrivez la capture.** Utilisez `{column}` pour que l'image soit l'anomalie.
4. **`verify`** — avant de lui faire confiance, et de nouveau après chaque modification de tableau de bord.
5. **`test`** — capture pour de vrai et renvoie la ligne de livraison à surveiller. **Il envoie un message aux destinataires de l'alerte** : un run de test est un envoi réel, et seule la comptabilité diffère d'un vrai déclenchement (`lastFiredAt` ne bouge pas, parce que rien ne s'est déclenché). Lancez-le quand un vrai message à ces adresses est ce que vous voulez ; `verify` est l'étape qui n'atteint personne.

## Permissions[​](#permissions "Lien direct vers Permissions")

Créer, modifier, supprimer et tester une alerte demande le rôle **creator** ou **admin** *et* un accès en édition au tableau de bord. En lire une — et `verify`, qui ne dérive rien que vous ne puissiez déjà lire — demande un accès en lecture. Un tableau de bord que vous ne pouvez pas modifier répond `404`, pas `403` : le refus ne confirme pas que l'identifiant existe.
