# Alertas que un agente puede escribir

Un panel te dice algo cuando lo abres. **Una alerta te avisa cuando no estabas mirando** — y esa es justo la parte que un agente no puede producir por sí solo: necesita estado alojado, datos frescos, un reloj de pared y algún sitio donde entregar.

Una alerta son cinco decisiones, así que la API tiene cinco campos:

| Campo     | Qué decide                                                      |
| --------- | --------------------------------------------------------------- |
| `when`    | El SQL cuyas **filas son la infracción**                        |
| `say`     | La frase que lee quien la recibe, rellenada con la primera fila |
| `every`   | Cada cuánto se mira la condición                                |
| `capture` | Qué imagen se toma, en Playwright                               |
| `to`      | Quién la recibe                                                 |

## El ejemplo completo[​](#el-ejemplo-completo "Enlace directo al El ejemplo completo")

```
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)   # cada selector se resuelve — no captura nada, no envía nada

client.alerts.test(alert.id)     # captura de verdad Y ENVÍA UN CORREO a finance@acme.com
```

La misma llamada en TypeScript, donde el SDK es 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); // cada selector se resuelve — no captura nada, no envía nada

await client.alerts.test(alert.id);   // captura de verdad Y ENVÍA UN CORREO a finance@acme.com
```

Por MCP es `create_alert` con los mismos cinco nombres, y luego `verify_alert` y `test_alert`.

## El contrato «truthy»[​](#el-contrato-truthy "Enlace directo al El contrato «truthy»")

**Cero filas significa no disparada. Una o más filas significa disparada.** No hay ningún comparador que puedas poner del revés ni ningún umbral que mantener sincronizado con la consulta: la cláusula `WHERE` *es* la condición.

La primera fila devuelta son los metadatos del mensaje, y `say` los convierte en palabras. Escribe la infracción y su explicación como una única consulta, para que la frase no pueda afirmar nada que los datos no digan:

```
SELECT region,

       ROUND(margin*100,1) AS margin_pct,

       10                  AS threshold_pct

FROM margins

WHERE margin < 0.10

ORDER BY margin
```

Lo que lee quien la recibe (la frase la genera el programa, así que queda en inglés):

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

Las filas extra son contexto, con un tope de cinco. Un marcador de `say` que nombra una columna que la consulta no hace `SELECT` se **rechaza al crear la alerta**, nombrando la columna y listando las disponibles — nunca un hueco en blanco, y nunca el literal `{region}` enviado por correo a alguien como si fuera un texto escrito por ti. Los alias cuentan, así que es `ROUND(margin*100,1) AS margin_pct` lo que hace que `{margin_pct}` se resuelva.

La forma antigua — `comparator` más `threshold` sobre una única celda numérica — se sigue aceptando como azúcar sintáctico. Prefiere la forma truthy: «cero filas» es inequívoco de una manera en que «el número no está por debajo del umbral» no lo es.

## La imagen muestra la infracción, no la vista por defecto[​](#la-imagen-muestra-la-infracción-no-la-vista-por-defecto "Enlace directo al La imagen muestra la infracción, no la vista por defecto")

`{column}` también funciona en `capture`, y ese es todo su sentido. Allí un marcador es un **valor**: `?f.region={region}` fija el filtro a la región que realmente se disparó, así que la alerta sobre West es una imagen de West. El enlace del mensaje se construye con la misma receta, de modo que la imagen y el enlace coinciden.

```
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>')` se dirige a un control por **el nombre de configuración que tú le diste** — `describe_dashboard_controls(viz_id)` por MCP, o `GET /v1/viz/{id}/controls`, lista los nombres y los valores que un panel acepta de verdad. Nunca una ruta CSS en crudo: esas se rechazan, porque se rompen en silencio la próxima vez que el marcado se mueve.

La receta se **analiza, nunca se ejecuta**. `page.evaluate`, `require`/`import`, las plantillas literales, la concatenación de cadenas, los comentarios y las rutas CSS vuelven cada uno como un rechazo que nombra la línea. Lo que se ejecuta es una pequeña receta declarativa que el carril de captura aplica construyendo la URL, algo determinista de una manera en que un clic no lo es.

## `every`: una cadena cron o una duración[​](#every-una-cadena-cron-o-una-duración "Enlace directo al every-una-cadena-cron-o-una-duración")

```
every="0 7 * * *"   # las 07:00 de cada día

every="15m"         # cada quince minutos

every="2h"          # cada dos horas

every="1d"          # una vez al día, a medianoche
```

Solo se aceptan los pasos que cubren su unidad de forma exacta. `7m` se rechaza: cron reinicia el paso al comienzo de la hora, así que `*/7` se dispararía a los :00, :07 … :56 y otra vez cuatro minutos después — no «cada siete minutos». El rechazo lista los pasos que sí funcionan.

Una expresión cron válida que **nunca puede ocurrir** — `0 0 30 2 *`, que pide un 30 de febrero — también se rechaza. Nada aguas abajo se quejaría jamás de ella: la alerta se quedaría `active`, nunca vencida, nunca evaluada, y todas las superficies la darían por sana para siempre.

Omite `every` con `when` definido y la alerta se evalúa a la cadencia propia del planificador.

## `to`: el canal se lee de la dirección[​](#to-el-canal-se-lee-de-la-dirección "Enlace directo al to-el-canal-se-lee-de-la-dirección")

```
to=[

    "finance@acme.com",                       # correo electrónico

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

    "usr_1a2b3c",                             # un id de usuario — notificación in-app

]
```

Las tres formas son disjuntas, así que no hay ningún canal que declarar junto a la dirección. Algo que parece una dirección y no es ninguna de las tres se rechaza en lugar de archivarse como un id de usuario que no existe.

## La garantía de caché cero[​](#la-garantía-de-caché-cero "Enlace directo al La garantía de caché cero")

**Una condición siempre se ejecuta saltándose la caché de SQL.** Ni lo pides ni puedes desactivarlo. Una alerta que se disparara sobre un resultado cacheado no sería una alerta — sería el informe de algo que fue cierto en algún momento. Sin lectura de caché, sin incremento del contador de aciertos, sin escritura de caché: la consulta va al motor cada vez que se evalúa.

## Dispara en la transición, no en cada comprobación[​](#dispara-en-la-transición-no-en-cada-comprobación "Enlace directo al Dispara en la transición, no en cada comprobación")

Entrar en la infracción dispara una vez. Permanecer en ella es deliberadamente silencioso. La alerta se rearma cuando la condición se despeja. Una métrica que oscila alrededor de su límite envía un mensaje, no uno por tick.

## El canario: ejecuta `verify` después de cambiar un panel[​](#el-canario-ejecuta-verify-después-de-cambiar-un-panel "Enlace directo al el-canario-ejecuta-verify-después-de-cambiar-un-panel")

`verify` es la única llamada que te dice si tu receta se sigue resolviendo.

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

if not result.verification.resolved:

    for step in result.verification.steps:

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

**No captura nada, no envía nada, no escribe ninguna fila y no mueve ninguna columna.** Ejecuta el mismo ejecutor que ejecuta una captura real, en modo solo-resolución, contra el propio código generado del panel — de modo que no puede desviarse de lo que hará la captura. Se informa de cada paso, no solo del primero: renombra tres selectores y te lo dirá de los tres.

Conviértelo en costumbre, porque el fallo que atrapa es invisible de otro modo:

> **Editas un panel. Renombras un filtro. La receta de la alerta sigue apuntando al nombre antiguo. Nada falla — hasta que alguien abre un correo con la imagen de lo que no era.**

Un paso en rojo **es** el cambio incompatible que acabas de hacer. Ejecuta `verify` tras cada edición de un panel que toque un id de página, un nombre de selector o un valor de filtro, y lo encontrarás cuando todavía puedes arreglarlo.

## Cada rechazo nombra el arreglo[​](#cada-rechazo-nombra-el-arreglo "Enlace directo al Cada rechazo nombra el arreglo")

Un `400` de estos endpoints está pensado para leerse y actuar en consecuencia, no para reintentarse (los mensajes de abajo son los que devuelve la API, en inglés):

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

## El orden en el que trabajar[​](#el-orden-en-el-que-trabajar "Enlace directo al El orden en el que trabajar")

1. **`describe_dashboard_controls(viz_id)`** — los nombres y los valores que este panel acepta de verdad. Saltárselo es adivinar los nombres de los selectores.
2. **Escribe el SQL.** Sus filas son la infracción; sus columnas son la frase.
3. **Escribe la captura.** Usa `{column}` para que la imagen sea la infracción.
4. **`verify`** — antes de confiar en ella, y otra vez tras cada edición del panel.
5. **`test`** — captura de verdad y devuelve la fila de entrega que hay que vigilar. **Envía un mensaje a los destinatarios de la alerta**: una ejecución de prueba es un envío real, y solo la contabilidad difiere de un disparo real (`lastFiredAt` no se mueve, porque no se disparó nada). Ejecútalo cuando lo que quieras sea un mensaje real a esas direcciones; `verify` es el paso que no llega a nadie.

## Permisos[​](#permisos "Enlace directo al Permisos")

Crear, editar, borrar y probar una alerta requiere el rol **creator** o **admin** *y* acceso de edición al panel. Leer una — y `verify`, que no deriva nada que no pudieras leer ya — requiere acceso de lectura. Un panel que no puedes editar responde `404`, no `403`: el rechazo no confirma que el id exista.
