Why it attaches to a dataset
Notifications hang off the dataset, not off the pipeline that fills it, and that distinction matters more than it first appears. A real development runs many lead-capture pipelines — one per landing page, per language, per variant. One customer has 25 of them, all writing a single leads dataset. A notification attached to a pipeline covers that pipeline alone, so the day someone launches landing page 26, its leads stop reaching anyone and nothing looks broken. Attaching to the dataset covers every writer, including the ones that don’t exist yet.Setting it up
PUT /v1/dataset-notifications takes the dataset slug and the full recipient
list. The list replaces what was there — send the complete set, not a delta.
GET /v1/dataset-notifications?dataset=<slug> reads the current state back —
recipients, whether it’s on, and the automation implementing it. That state is
read from the automation itself, so there is no second copy of the setting to
drift out of sync with reality.
Passing an empty recipient list turns email off. It removes the declaration only
when exclusion is absent too; with an exclusion rule, a classifier-only
notification remains active without a send_email step.
Turning notifications on never delivers a backlog. Where the dataset currently
stands is recorded at the moment you configure the notification, so only rows
arriving afterwards are announced — including the very first one. Turning
notifications off removes the automation rather than pausing it, for the same
reason: switching back on months later must not dump everything that
accumulated in between into someone’s inbox.
What arrives
Ordinary arrival sends one email per row, so each lead is its own actionable message: who they are and how to reach them first, then what they told the form, then the campaign that brought them. Tracking identifiers — click ids, page URLs, the flags the pipeline stamps on the way in — are join keys for machines and appear nowhere in it. The language they submitted in does appear, because it decides who on the desk can take the call. A burst collapses into a single digest instead of flooding the inbox: either more rows than one run reads, or more messages than one run may send. Every message is tagged with context recording the dataset and which row it concerned —{"kind": "lead_alert", "lead_email": "ana@example.com"}
— so your own product can later show “we alerted the team about this lead” on
the lead itself. This is the only way that link can be made: the recipient is the
sales desk, so nothing about the address says which lead the message was about.
The same lead arriving twice
A lead dataset is not a log. Capture pipelines upsert on the address, so a landing page that asks for an email first and the rest a minute later writes the same lead twice — one row in the dataset, two writes. Announcing that as two leads is wrong, and it is not a rare shape: on one development a quarter of all leads are written more than once, far enough apart that no amount of batching would hide it. The write settles it. Every row records the moment the dataset created it, in a column namederdo_created_at, and a second write to the same row leaves that
moment alone however far the arrival column moves. A notification keeps its
position in that column as well as in the arrival column, so a row created no
later than the point it has already reached is a lead it has announced —
including one that arrived in a burst too large for a single run to read.
On top of that it also remembers which leads it has announced by address, and
what was known about each at the time, which is what covers rows written before
erdo_created_at existed. Either way a row it recognises is not announced again,
and the run result counts it. Two leads that both arrived without an address are
still two leads — an absent address identifies nobody, so neither is suppressed.
Cost
With neitherskip_when nor exclusion, nothing per run: the automation is a
deterministic script fired by a dataset event, with no polling. Rules that ask
Erdo to judge a row use one LLM judgement per new row.
Skipping rows you describe
Not every new row deserves an email. Public lead forms attract junk — typo’d addresses that can never receive a reply, gibberish, staff tests — and some desks simply don’t want alerts for rows missing what they need to act. Which rows those are is your call, not the platform’s, so you describe them:skip_when means every new row is announced.
One thing your description can lean on: a leads dataset collects leads from
several surfaces, and each lead row names its own in a source column —
landing_page_form (a landing page’s lead form), landing_followup_page (the
scheduler on a follow-up page), widget_booking_form (a website widget’s “Book
a call” form), widget_conversation (details a visitor gave the widget’s
assistant in conversation), phone_incoming_call (someone who rang your agent’s
number). So “anyone who rang rather than filled in a form” is a rule the
judgement can decide from the row itself, and a desk that only wants to hear
about typed submissions can say so. Rows captured before the column existed
carry a blank source, so say what should happen to those rather than assuming
a value.
A phone caller usually leaves no email address, only a number. The alert still
goes out and names the phone — never announcing a lead is the worse failure — so
write your skip_when for what you actually want to hear about. A lead
autoreply on the same dataset is the piece that cannot
act on such a row: with no address to send to, its send step is recorded as
skipped with the reason and the row’s other steps carry on, rather than the row
failing on an email that could never have been sent.
Durably excluding rows that are not genuine enquiries
skip_when controls only the interruption. To make a judgement visible in
ordinary dataset reads and counts, add a separate durable rule:
automated_submission records
automation/bot evidence, while invalid_contact_information separately records
placeholder, impossible, reserved, or non-contactable identity. The judgement
also carries a reason for each axis. Erdo may use aggregate peer correlation
from rows in the same run — shared click identifiers or an exact request/form
fingerprint, timing, distinct contacts, and reversed initials — but never puts
another row’s raw contact text into the prompt.
When exclude is true, Erdo first commits a default dataset filter that keeps
every row except that exact source key. The automatic filter has a reserved,
deterministic name, so retrying the same row is idempotent while a different row
always gets its own filter. Only after that commit does Erdo suppress the alert.
The filter’s read-only metadata contains the two judgement axes, their reasons,
the rule, key target, display handle, job, and execution provenance.
Exclusion fails open. A judgement or filter write error is recorded on the
automation run; the row remains visible and, when recipients are configured,
the email sends. A live-refresh failure after a successful filter commit is
also recorded, but does not undo the committed exclusion. Exclusion mode does
not use the digest shortcut: every row is judged and advances the declaration’s
ledger exactly once, including erroring judgements.
For the design and threat-model details, see the engineering proposal.
Which column marks arrival
Erdo detects the column recording when a row arrived (submitted_at,
created_at, received_at, and similar) by probing the dataset’s real columns
when you configure the notification. If your dataset names it something
unconventional, pass timestamp_column explicitly.
Detection happens at configuration time on purpose: a dataset with no usable
column is an immediate error you can fix, rather than an automation that looks
perfectly healthy and quietly never notifies anyone.
Whichever column you name, it has to hold timestamps. The notification starts
from the newest value that column already carries and compares later rows against
it as text, so a column of free-text answers — a “when are you looking to buy?”
question answered “Immediately” — would leave every row that arrives afterwards
sorting below it, and nobody would be emailed again. Erdo checks what the column
actually holds and refuses the configuration when it is not a timestamp, naming
the value it found.
An existing notification is checked the same way as it runs, on both the point it
is starting from and the one it is about to record. Save it again against a column
that records arrival and it recovers by itself on its next run: the unusable
starting point is dropped and the notification resumes from where the dataset now
stands, with no backlog emailed. Until then its runs fail, naming the column and
the value — a run that fails sends nothing and leaves the starting point where it
was, so the leads it could not order are still waiting once the column is
corrected.
The recipient list is also the permission
Sending email is a consequential action, so an automation cannot do it unless something authorises it — and an unattended run has nobody to ask, which is why a script that emails on a schedule needs a standing approval granted in advance. A notification does not need one, because the recipient list is the authorisation. Saving a notification records permission for that automation to email exactly the addresses you listed. Remove an address and the permission goes with it; remove every recipient and it is withdrawn entirely even when a classifier-only exclusion declaration stays active. The automation cannot reach an inbox you did not name, even if something goes wrong with the message it composes. This is the difference between a notification and a hand-written script that sends mail. The script names its recipients in code nobody reviewed as a decision about who may be written to, so it is refused until somebody grants it. A notification is that decision, made when you saved it.Limits
At most 10 recipients per dataset — each recipient’s email is one action invocation, and one run may make ten, so a longer list could never be delivered. Sends also count toward your organization’s daily distinct-recipient limit of 50.Related
Sent email
Read back everything that went out, including these alerts.
Automations
The run history of the automation behind a notification.

