# Alarme, die ein Agent schreiben kann

Ein Dashboard sagt Ihnen etwas, wenn Sie es öffnen. **Ein Alarm sagt es Ihnen, wenn Sie gerade nicht hingesehen haben** — und genau das ist der Teil, den ein Harness nicht aus eigener Kraft erzeugen kann: er braucht gehosteten Zustand, frische Daten, eine Wanduhr und irgendwohin zuzustellen.

Ein Alarm besteht aus fünf Entscheidungen, deshalb hat die API fünf Felder:

| Feld      | Was es entscheidet                                                |
| --------- | ----------------------------------------------------------------- |
| `when`    | Das SQL, dessen **Zeilen die Verletzung sind**                    |
| `say`     | Der Satz, den die Empfängerin liest, gefüllt aus der ersten Zeile |
| `every`   | Wie oft die Bedingung geprüft wird                                |
| `capture` | Welches Bild aufgenommen wird, in Playwright                      |
| `to`      | Wer ihn erhält                                                    |

## Das durchgearbeitete Beispiel[​](#das-durchgearbeitete-beispiel "Direkter Link zur Das durchgearbeitete Beispiel")

```
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)   # jeder Selektor löst auf — nimmt nichts auf, sendet nichts

client.alerts.test(alert.id)     # nimmt wirklich auf UND MAILT an finance@acme.com
```

Derselbe Aufruf in TypeScript, wo das SDK camelCase verwendet:

```
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); // jeder Selektor löst auf — nimmt nichts auf, sendet nichts

await client.alerts.test(alert.id);   // nimmt wirklich auf UND MAILT an finance@acme.com
```

Über MCP ist es `create_alert` mit denselben fünf Namen, danach `verify_alert` und `test_alert`.

## Der Truthy-Vertrag[​](#der-truthy-vertrag "Direkter Link zur Der Truthy-Vertrag")

**Null Zeilen heißt nicht ausgelöst. Eine Zeile oder mehr heißt ausgelöst.** Es gibt keinen Vergleichsoperator, den man verkehrt herum setzen kann, und keinen Schwellwert, den man mit der Abfrage synchron halten müsste: die `WHERE`-Klausel *ist* die Bedingung.

Die erste zurückgegebene Zeile sind die Metadaten der Nachricht, und `say` macht Worte daraus. Schreiben Sie die Verletzung und ihre Erklärung als eine einzige Abfrage, damit der Satz nichts behaupten kann, was die Daten nicht hergeben:

```
SELECT region,

       ROUND(margin*100,1) AS margin_pct,

       10                  AS threshold_pct

FROM margins

WHERE margin < 0.10

ORDER BY margin
```

Was die Empfängerin liest (der Satz wird vom Programm erzeugt und bleibt daher englisch):

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

Weitere Zeilen sind Kontext, gedeckelt bei fünf. Ein `say`-Platzhalter, der eine Spalte nennt, die die Abfrage nicht per `SELECT` liefert, wird **schon beim Anlegen des Alarms abgelehnt** — mit dem Namen der Spalte und einer Liste der verfügbaren; nie eine Lücke, und nie das Literal `{region}`, das jemandem zugemailt wird, als wäre es ein Text, den Sie geschrieben hätten. Aliase zählen: `ROUND(margin*100,1) AS margin_pct` ist es, was `{margin_pct}` auflösen lässt.

Die ältere Form — `comparator` plus `threshold` über eine einzelne numerische Zelle — wird weiterhin als syntaktischer Zucker akzeptiert. Bevorzugen Sie die Truthy-Form: „null Zeilen“ ist eindeutig auf eine Weise, wie „die Zahl liegt nicht unter dem Schwellwert“ es nicht ist.

## Das Bild zeigt die Verletzung, nicht die Standardansicht[​](#das-bild-zeigt-die-verletzung-nicht-die-standardansicht "Direkter Link zur Das Bild zeigt die Verletzung, nicht die Standardansicht")

`{column}` funktioniert auch in `capture`, und genau darum geht es. Ein Platzhalter dort ist ein **Wert**: `?f.region={region}` heftet den Filter an die Region, die tatsächlich ausgelöst hat — der Alarm über West ist also ein Bild von West. Der Link in der Nachricht wird aus demselben Rezept gebaut, Bild und Link stimmen daher überein.

```
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>')` adressiert ein Bedienelement über **den Konfigurationsnamen, den Sie ihm gegeben haben** — `describe_dashboard_controls(viz_id)` über MCP oder `GET /v1/viz/{id}/controls` listet die Namen und Werte auf, die ein Dashboard tatsächlich akzeptiert. Niemals ein roher CSS-Pfad: die werden abgelehnt, weil sie still brechen, sobald sich das Markup das nächste Mal verschiebt.

Das Rezept wird **geparst, niemals ausgeführt**. `page.evaluate`, `require`/`import`, Template-Literale, String-Verkettung, Kommentare und CSS-Pfade kommen jeweils als Ablehnung zurück, die die Zeile benennt. Was läuft, ist ein kleines deklaratives Rezept, das die Capture-Spur anwendet, indem sie die URL baut — deterministisch auf eine Weise, wie ein Klick es nicht ist.

## `every`: ein Cron-String oder eine Dauer[​](#every-ein-cron-string-oder-eine-dauer "Direkter Link zur every-ein-cron-string-oder-eine-dauer")

```
every="0 7 * * *"   # 07:00 Uhr an jedem Tag

every="15m"         # alle fünfzehn Minuten

every="2h"          # alle zwei Stunden

every="1d"          # einmal täglich um Mitternacht
```

Akzeptiert werden nur Schritte, die ihre Einheit gleichmäßig ausfüllen. `7m` wird abgelehnt: Cron startet einen Schritt zur vollen Stunde neu, `*/7` würde also um :00, :07 … :56 feuern und dann schon vier Minuten später wieder — nicht „alle sieben Minuten“. Die Ablehnung listet die Schritte auf, die funktionieren.

Ein Cron-Ausdruck, der gültig ist und **nie eintreten kann** — `0 0 30 2 *`, der den 30. Februar verlangt — wird ebenfalls abgelehnt. Nachgelagert würde sich nie etwas darüber beschweren: der Alarm bliebe `active`, nie fällig, nie ausgewertet, und jede Oberfläche würde ihn für immer als gesund melden.

Lassen Sie `every` weg, während `when` gesetzt ist, und der Alarm wird im eigenen Takt des Schedulers ausgewertet.

## `to`: der Kanal wird an der Adresse abgelesen[​](#to-der-kanal-wird-an-der-adresse-abgelesen "Direkter Link zur to-der-kanal-wird-an-der-adresse-abgelesen")

```
to=[

    "finance@acme.com",                       # E-Mail

    "https://hooks.slack.com/services/T/B/X", # eingehender Slack-Webhook

    "usr_1a2b3c",                             # eine Benutzer-ID — In-App-Benachrichtigung

]
```

Die drei Formen sind disjunkt, es gibt also keinen Kanal, den man neben der Adresse deklarieren müsste. Etwas, das wie eine Adresse aussieht und keine von beiden ist, wird abgelehnt, statt als Benutzer-ID abgelegt zu werden, die es gar nicht gibt.

## Die Cache-Null-Garantie[​](#die-cache-null-garantie "Direkter Link zur Die Cache-Null-Garantie")

**Eine Bedingung läuft immer mit umgangenem SQL-Cache.** Sie fordern das nicht an, und Sie können es nicht abschalten. Ein Alarm, der auf einem gecachten Ergebnis auslöst, wäre kein Alarm — er wäre der Bericht über etwas, das irgendwann einmal wahr war. Kein Cache-Lesen, kein Hochzählen der Trefferzahl, kein Cache-Schreiben: die Abfrage geht bei jeder Auswertung an die Engine.

## Auf den Übergang feuern, nicht bei jeder Prüfung[​](#auf-den-übergang-feuern-nicht-bei-jeder-prüfung "Direkter Link zur Auf den Übergang feuern, nicht bei jeder Prüfung")

Das Eintreten in die Verletzung feuert einmal. Darin zu verharren ist bewusst still. Der Alarm schärft sich neu, wenn die Bedingung sich auflöst. Eine Metrik, die um ihren Grenzwert flattert, sendet eine Nachricht, nicht eine pro Tick.

## Der Kanarienvogel: `verify` nach jeder Dashboard-Änderung[​](#der-kanarienvogel-verify-nach-jeder-dashboard-änderung "Direkter Link zur der-kanarienvogel-verify-nach-jeder-dashboard-änderung")

`verify` ist der eine Aufruf, der Ihnen sagt, dass Ihr Rezept noch auflöst.

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

if not result.verification.resolved:

    for step in result.verification.steps:

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

Es **nimmt nichts auf, sendet nichts, schreibt keine Zeile und bewegt keine Spalte.** Es lässt denselben Executor laufen, den eine echte Aufnahme benutzt, im Nur-Auflösen-Modus, gegen den generierten Code des Dashboards selbst — es kann also nicht von dem abdriften, was die Aufnahme tun wird. Jeder Schritt wird gemeldet, nicht nur der erste: benennen Sie drei Selektoren um, und Sie erfahren von dreien.

Machen Sie es sich zur Gewohnheit, denn der Fehler, den es fängt, ist sonst unsichtbar:

> **Sie bearbeiten ein Dashboard. Sie benennen einen Filter um. Das Rezept des Alarms zeigt weiterhin auf den alten Namen. Nichts schlägt fehl — bis eine Empfängerin eine E-Mail mit dem Bild der falschen Sache darin öffnet.**

Ein roter Schritt **ist** die brechende Änderung, die Sie gerade gemacht haben. Führen Sie `verify` nach jeder Dashboard-Bearbeitung aus, die eine Seiten-ID, einen Selektornamen oder einen Filterwert berührt, und Sie finden sie, solange Sie sie noch beheben können.

## Jede Ablehnung benennt die Behebung[​](#jede-ablehnung-benennt-die-behebung "Direkter Link zur Jede Ablehnung benennt die Behebung")

Ein `400` von diesen Endpunkten ist zum Lesen und Handeln gedacht, nicht zum Wiederholen (die Meldungen unten sind die, die die API zurückgibt, auf Englisch):

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

## Die Reihenfolge, in der man arbeitet[​](#die-reihenfolge-in-der-man-arbeitet "Direkter Link zur Die Reihenfolge, in der man arbeitet")

1. **`describe_dashboard_controls(viz_id)`** — die Namen und Werte, die dieses Dashboard tatsächlich akzeptiert. Es zu überspringen heißt, Selektornamen zu raten.
2. **Schreiben Sie das SQL.** Seine Zeilen sind die Verletzung; seine Spalten sind der Satz.
3. **Schreiben Sie die Aufnahme.** Nutzen Sie `{column}`, damit das Bild die Verletzung ist.
4. **`verify`** — bevor Sie ihm vertrauen, und erneut nach jeder Dashboard-Bearbeitung.
5. **`test`** — nimmt wirklich auf und gibt die Zustellzeile zurück, die man beobachten kann. **Es mailt den Empfängern der Warnung**: ein Testlauf ist ein echter Versand, und nur die Buchführung unterscheidet sich von einem echten Auslösen (`lastFiredAt` bewegt sich nicht, weil nichts ausgelöst hat). Führen Sie es aus, wenn eine echte Nachricht an diese Adressen genau das ist, was Sie wollen; `verify` ist der Schritt, der niemanden erreicht.

## Berechtigungen[​](#berechtigungen "Direkter Link zur Berechtigungen")

Einen Alarm anzulegen, zu bearbeiten, zu löschen und zu testen erfordert die Rolle **creator** oder **admin** *und* Bearbeitungszugriff auf das Dashboard. Einen zu lesen — und `verify`, das nichts ableitet, was Sie nicht ohnehin lesen könnten — erfordert Lesezugriff. Ein Dashboard, das Sie nicht bearbeiten dürfen, antwortet mit `404`, nicht `403`: die Ablehnung bestätigt nicht, dass die ID existiert.
