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
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
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
{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="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=[
"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
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
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
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
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
describe_dashboard_controls(viz_id)— the names and values this dashboard actually accepts. Skipping it means guessing at selector names.- Write the SQL. Its rows are the breach; its columns are the sentence.
- Write the capture. Use
{column}so the picture is the breach. verify— before you trust it, and again after every dashboard edit.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 (lastFiredAtdoes not move, because nothing fired). Run it when a real message to those addresses is what you want;verifyis the step that reaches nobody.
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.