# Alertas que um agente consegue escrever

Um painel te conta algo quando você o abre. **Um alerta te conta quando você não estava olhando** — e essa é justamente a parte que um agente não consegue produzir sozinho: ele precisa de estado hospedado, dados frescos, um relógio de parede e algum lugar para entregar.

Um alerta são cinco decisões, então a API tem cinco campos:

| Campo     | O que ele decide                                                     |
| --------- | -------------------------------------------------------------------- |
| `when`    | O SQL cujas **linhas são a violação**                                |
| `say`     | A frase que o destinatário lê, preenchida a partir da primeira linha |
| `every`   | De quanto em quanto tempo a condição é observada                     |
| `capture` | Que imagem é tirada, em Playwright                                   |
| `to`      | Quem o recebe                                                        |

## O exemplo completo[​](#o-exemplo-completo "Link direto para O exemplo 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 seletor resolve — não captura nada, não envia nada

client.alerts.test(alert.id)     # captura de verdade E ENVIA UM E-MAIL para finance@acme.com
```

A mesma chamada em TypeScript, onde o SDK é 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 seletor resolve — não captura nada, não envia nada

await client.alerts.test(alert.id);   // captura de verdade E ENVIA UM E-MAIL para finance@acme.com
```

Via MCP, é `create_alert` com os mesmos cinco nomes, depois `verify_alert` e `test_alert`.

## O contrato "truthy"[​](#o-contrato-truthy "Link direto para O contrato \"truthy\"")

**Zero linhas significa não disparado. Uma linha ou mais significa disparado.** Não há comparador para inverter por engano nem limiar para manter em sincronia com a consulta: a cláusula `WHERE` *é* a condição.

A primeira linha retornada são os metadados da mensagem, e `say` os transforma em palavras. Escreva a violação e sua explicação como uma única consulta, para que a frase não possa afirmar nada que os dados não digam:

```
SELECT region,

       ROUND(margin*100,1) AS margin_pct,

       10                  AS threshold_pct

FROM margins

WHERE margin < 0.10

ORDER BY margin
```

O que o destinatário lê (a frase é produzida pelo programa, então permanece em inglês):

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

Linhas extras são contexto, com teto de cinco. Um marcador de `say` que nomeia uma coluna que a consulta não faz `SELECT` é **recusado no momento em que você cria o alerta**, com a coluna nomeada e as disponíveis listadas — nunca um espaço em branco, e nunca o literal `{region}` enviado por e-mail a alguém como se fosse um texto escrito por você. Os aliases contam, então é `ROUND(margin*100,1) AS margin_pct` que faz `{margin_pct}` resolver.

A forma mais antiga — `comparator` mais `threshold` sobre uma única célula numérica — ainda é aceita como açúcar sintático. Prefira a forma truthy: "zero linhas" é inequívoco de um jeito que "o número não está abaixo do limiar" não é.

## A imagem mostra a violação, não a visão padrão[​](#a-imagem-mostra-a-violação-não-a-visão-padrão "Link direto para A imagem mostra a violação, não a visão padrão")

`{column}` também funciona em `capture`, e é esse o ponto. Um marcador ali é um **valor**: `?f.region={region}` fixa o filtro na região que de fato disparou, então o alerta sobre West é uma imagem de West. O link da mensagem é construído a partir da mesma receita, então a imagem e o link concordam.

```
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>')` endereça um controle pelo **nome de configuração que você deu a ele** — `describe_dashboard_controls(viz_id)` via MCP, ou `GET /v1/viz/{id}/controls`, lista os nomes e os valores que um painel realmente aceita. Nunca um caminho CSS cru: esses são recusados, porque quebram em silêncio na próxima vez que a marcação se mexer.

A receita é **analisada, nunca executada**. `page.evaluate`, `require`/`import`, template literals, concatenação de strings, comentários e caminhos CSS voltam cada um como uma recusa que nomeia a linha. O que roda é uma pequena receita declarativa que a trilha de captura aplica montando a URL, o que é determinístico de um jeito que um clique não é.

## `every`: uma string cron ou uma duração[​](#every-uma-string-cron-ou-uma-duração "Link direto para every-uma-string-cron-ou-uma-duração")

```
every="0 7 * * *"   # 07:00 todos os dias

every="15m"         # a cada quinze minutos

every="2h"          # a cada duas horas

every="1d"          # uma vez por dia à meia-noite
```

Só são aceitos passos que preenchem sua unidade de forma exata. `7m` é recusado: o cron reinicia o passo na virada da hora, então `*/7` dispararia em :00, :07 … :56 e de novo quatro minutos depois — não "a cada sete minutos". A recusa lista os passos que funcionam.

Uma expressão cron válida que **nunca pode ocorrer** — `0 0 30 2 *`, pedindo 30 de fevereiro — também é recusada. Nada rio abaixo jamais reclamaria dela: o alerta ficaria `active`, nunca vencido, nunca avaliado, e toda superfície o reportaria como saudável para sempre.

Omita `every` com `when` definido e o alerta é avaliado na cadência do próprio agendador.

## `to`: o canal é lido do endereço[​](#to-o-canal-é-lido-do-endereço "Link direto para to-o-canal-é-lido-do-endereço")

```
to=[

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

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

    "usr_1a2b3c",                             # um id de usuário — notificação in-app

]
```

As três formas são disjuntas, então não há canal a declarar ao lado do endereço. Algo que parece um endereço e não é nenhum dos três é recusado em vez de ser arquivado como um id de usuário que não existe.

## A garantia de cache zero[​](#a-garantia-de-cache-zero "Link direto para A garantia de cache zero")

**Uma condição é sempre executada com o cache de SQL contornado.** Você não pede por isso e não pode desligar. Um alerta que disparasse sobre um resultado em cache não seria um alerta — seria o relato de algo que foi verdade em algum momento. Sem leitura de cache, sem incremento na contagem de acertos, sem escrita de cache: a consulta vai ao motor toda vez que é avaliada.

## Dispare na transição, não a cada verificação[​](#dispare-na-transição-não-a-cada-verificação "Link direto para Dispare na transição, não a cada verificação")

Entrar na violação dispara uma vez. Permanecer nela é deliberadamente silencioso. O alerta se rearma quando a condição se resolve. Uma métrica que oscila em torno do seu limite envia uma mensagem, não uma por tique.

## O canário: rode `verify` depois de mudar um painel[​](#o-canário-rode-verify-depois-de-mudar-um-painel "Link direto para o-canário-rode-verify-depois-de-mudar-um-painel")

`verify` é a única chamada que te diz que a sua receita ainda resolve.

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

if not result.verification.resolved:

    for step in result.verification.steps:

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

Ele **não captura nada, não envia nada, não escreve nenhuma linha e não move nenhuma coluna.** Ele roda o mesmo executor que uma captura real roda, em modo só-resolução, contra o próprio código gerado do painel — então não pode divergir do que a captura fará. Cada passo é reportado, não apenas o primeiro: renomeie três seletores e você é avisado sobre três.

Faça disso um hábito, porque a falha que ele pega é invisível de outra forma:

> **Você edita um painel. Você renomeia um filtro. A receita do alerta ainda aponta para o nome antigo. Nada falha — até um destinatário abrir um e-mail com a imagem da coisa errada dentro.**

Um passo vermelho **é** a mudança incompatível que você acabou de fazer. Rode `verify` depois de cada edição de painel que toque um id de página, um nome de seletor ou um valor de filtro, e você a encontra enquanto ainda dá para consertar.

## Toda recusa nomeia a correção[​](#toda-recusa-nomeia-a-correção "Link direto para Toda recusa nomeia a correção")

Um `400` desses endpoints existe para ser lido e agido, não repetido (as mensagens abaixo são as que a API retorna, em 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'.
```

## A ordem em que trabalhar[​](#a-ordem-em-que-trabalhar "Link direto para A ordem em que trabalhar")

1. **`describe_dashboard_controls(viz_id)`** — os nomes e valores que este painel realmente aceita. Pular isso é chutar nomes de seletores.
2. **Escreva o SQL.** Suas linhas são a violação; suas colunas são a frase.
3. **Escreva a captura.** Use `{column}` para que a imagem seja a violação.
4. **`verify`** — antes de confiar nele, e de novo depois de cada edição do painel.
5. **`test`** — captura de verdade e retorna a linha de entrega para acompanhar. **Ele envia uma mensagem aos destinatários do alerta**: uma execução de teste é um envio real, e apenas a contabilidade difere de um disparo verdadeiro (`lastFiredAt` não se move, porque nada disparou). Execute-o quando uma mensagem real para esses endereços for o que você quer; `verify` é o passo que não alcança ninguém.

## Permissões[​](#permissões "Link direto para Permissões")

Criar, editar, excluir e testar um alerta exige o papel de **creator** ou **admin** *e* acesso de edição ao painel. Ler um — e `verify`, que não deriva nada que você já não pudesse ler — exige acesso de visualização. Um painel que você não pode editar responde `404`, não `403`: a recusa não confirma que o id existe.
