Why it attaches to a dataset
Row actions 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. Anything attached to a pipeline covers that pipeline alone, so the day someone launches landing page 26, its rows stop being acted on and nothing looks broken. Attaching to the dataset covers every writer, including the ones that don’t exist yet.Declaring one
PUT /v1/dataset-row-actions takes the dataset, a name for this declaration, and
the steps. The declaration replaces whatever was there under the same name.
GET /v1/dataset-row-actions?dataset=<slug> lists everything declared on a
dataset; add &name=<name> to read one. That state is read from the automations
themselves, so there is no second copy of the declaration to drift out of sync
with reality.
Passing "enabled": false removes a declaration.
Validate before you activate
Add"validate_only": true to prepare the exact declaration without changing
anything. Erdo resolves the dataset and edit access, checks the real timestamp
and identity columns, validates after, normalizes the backfill range, builds
and compiles the managed script, and confirms that any first-party email step
has a safely bounded standing-grant shape. It then returns before any job,
trigger, grant, or run is created or changed. The approval service persists the
grant only on the subsequent save, so an infrastructure failure there can still
make that write fail visibly.
The response has "validated_only": true and an impact object. Read all of
these fields before sending the same declaration without validate_only:
operationiscreate,update,remove, orno_op.backfill_requestedsays this request explicitly asks to replay history.backfill_in_progresssays an earlier replay still has historical rows left.pending_runsays an already-queued run or dataset debounce exists.would_run_nowsays the write would start a run immediately.would_contact_historical_rowsis the direct safety check for an outreach declaration: it is true for a requested or unfinished backfill.
summary states the same impact in plain English. An ordinary edit with no
backfill does not replay rows the declaration has already settled. It can still
affect an unfinished backfill or a run already waiting for newly arrived rows,
which is why those are reported separately.
For erdo/send_email, preflight also checks the first-party action schema and
the recipient grant. A lead autoreply must address literal email addresses or
use exactly {{row.email}} (with email replaced by the dataset’s recognized
identity column) on every email step. A recipient derived from another field or
from a mixed dynamic shape is rejected because Erdo cannot safely authorize an
unattended send to it.
For a lead autoreply, set key_column to the verified email column even when
the capture pipeline itself upserts on lid. This is the one identity choice
specific to autoreplies: it bounds the standing approval and deduplication to
the recipient address. lid remains separate and is used only to personalize
and attribute the follow-up-page URL.
Script steps receive a read-only row and a read-only run.rows batch. The whole
batch is captured before any step runs, so a script cannot rewrite another row’s
address or replace, remove or reorder the rows later steps will process. Reading
the batch to compose a digest still works; return computed values from the script
for later steps to use through result. Test-send rehearsals use the same rule.
A row whose recipient resolves to nothing — a lead captured on a phone call, for
instance, who left a number and no address — is recorded as skipped for the
send step, with the reason no recipient address on this row, instead of being
invoked and failing. The rest of that row’s steps run as usual: nothing was
sent, so nothing answered, and a fallback step behind the send still gets its
turn.
Test-send the message to yourself
validate_only proves the declaration is well formed. It cannot tell you
whether the message reads properly, because that is a question about words —
whether the greeting your script composes gets the first name right, whether the
subject line reads well beside a real one in an inbox, whether a placeholder you
half-remembered resolves at all.
POST /v1/dataset-row-actions/test-send answers that. Give it the dataset, the
steps you are about to declare, one sample row, and deliver_to — the address
the rendered message should be delivered to. Erdo runs your script steps against
that row and renders the email through the same template engine the live
automation uses, so what lands in your inbox is the message a real row would
receive.
{column, value} entries rather than an object,
because a map of arbitrary values cannot cross the API schema — the same reason
a step’s input is raw JSON. Every value is text, which is what a template
resolves anyway.
Two things bound what a test send can do, and both are worth knowing before you
rely on it:
deliver_tois the only address it can ever reach. Whatever the declaration’s owntorenders to — the sample row’s address, a literal, a placeholder you mistyped — it is replaced withdeliver_tobefore the message is sent, and any recipient name the row rendered is dropped. Point a sample row at a real lead’s address and that lead still hears nothing.- The steps may only be scripts and the one email. A test send accepts zero
or more
scriptsteps followed by exactly oneerdo/send_emailstep. Any other action — an enrichment, a web search, a provider write — is refused, so asking to preview a message can never invoke something paid or something an approval gate exists to hold. Declare the full list withPUT /v1/dataset-row-actionsand test-send the message part on its own.
{{?column}} for a value to use when it is there and do without
when it is not. A placeholder mixed with surrounding text keeps what it found
rather than emptying the field, so About {{missing}} still renders and sends
with the gap left in — which is also what a live run does, and a good reason to
read the preview rather than trust the shape.
A script step that throws is reported the same way: nothing is sent and
skipped names the step and the error. A live run would carry on to the email
with whatever the step before it produced and report the error on the run, so
this is the one place a test send deliberately stops short — emailing a
half-rendered draft while saying nothing about the exception would be the worst
of both.
The send counts toward your organization’s daily distinct-recipient limit and
leaves from the same address a real send would, so what you receive is a fair
preview of the whole thing. It is recorded in the sent-email log with
test_send set on its context, alongside whatever context the declaration
itself carries — so POST /v1/emails-query with
{"context": {"test_send": "true"}} lists every rehearsal, and a message about
a real lead is never confused with one.
Ask an agent to set up a lead autoreply
You can hand Erdo approved single-language or multilingual copy and ask it to email each new lead immediately. The agent uses the same row-action declaration shown above; it does not create a polling job or send the copy itself while setting it up. The setup checks the live system before it writes anything: every active lead-capture pipeline must feed one leads dataset, the dataset must have a real email column, and any scheduling button must point to a published follow-up page. It also reads the sending identity. For a message that does not invite a reply, an unverified domain makes the agent stop and ask you to accept mail leaving fromhello@mail.erdo.ai instead of quietly using the fallback.
That fallback is only suitable when the message does not ask for a reply. Its
Reply-To is hello@erdo.ai, not your nominated mailbox. If the approved copy
invites replies, or the workflow requires replies to reach your team, the agent
requires an active sending domain and a working route reported by Erdo: either
receiving with forwarding configured, or a sending-only identity whose Reply-To
forwards to your mailbox. It refuses activation while DNS or that reply route is
unfinished, even if you would otherwise accept the fallback sender.
Copy you provide is treated as approved content, not a writing prompt. Erdo
keeps each language version unchanged and only normalizes the retired
{{FOLLOWUP_PAGE}} marker to [[FOLLOWUP_PAGE]]. Put that marker on its own
line where the scheduling button belongs; use
[[FOLLOWUP_PAGE|Choose your time]] to supply the button label.
When copy includes that scheduling marker, the agent reads the page’s current
visibility and visitor URL from the page list. Public pages carry their exact
environment or custom-domain public_url; private pages do not. The agent uses
that returned URL verbatim and never publishes a draft merely to inspect it.
The declaration is always named lead-autoreply and is read before it is
replaced. The agent first submits the complete declaration with
validate_only: true and checks the returned impact summary; that preflight
does not save the declaration, contact anyone, or raise an approval card. The
subsequent mutating call does pause for Erdo’s systemic human approval card —
whether it activates, replaces, removes, or backfills the declaration — so the
exact live change remains a human decision. Activation proceeds only when
the preflight confirms that no backfill was requested or already running, no run
is pending or would start immediately, and no historical rows would be
contacted. A normal write happens only when you explicitly asked to activate it,
and the result is read back after saving. It carries no backfill and leaves
on_update at its default skip, so enabling it neither contacts historical
leads nor emails the same address again when their row is completed or
updated. Setup is verified structurally — Erdo never tests it by sending to a
real lead. To read the message itself before it goes live, use
a test send, which delivers the rendered
email to an address you name and to nobody else.
By default a row action never works through a backlog. Where the dataset stands is
recorded the moment you declare it, so only rows arriving afterwards are acted on
— including the very first one. Removing one deletes the automation rather than
pausing it, for the same reason: switching back on months later must not suddenly
invoke a paid action for everything that accumulated in between. Ask for a
backfill when you want the history done.
Skipping rows before any action runs
Useskip_when when some new rows must not reach this declaration at all:
skip_when; an uncertain check
never silently suppresses work. When a row has an email address, every
definitive sign from DNS that its domain cannot receive email — the domain does
not exist, it declares that it accepts no mail, its only mail servers are
unreachable by design, or it has no mail server and no address at all — is
given to the judge as checked evidence, not used as a rule by itself. Anything
short of definitive, including a DNS error, simply contributes no evidence.
Form/contact values are kept separate from request, advertising, browser, page
and location context. The latter can prove a visit happened, but—unless your rule
asks about that context—it cannot prove that the name, email or phone typed into
the form is genuine. Every row value is treated as untrusted data, never as an
instruction to the judge.
skip_when belongs to this declaration only. Row actions on the same dataset
run independently, so every sibling declaration that must suppress the same row
needs the policy declared on it as well.
A leads dataset now holds leads from more than one surface, and every 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), sms_incoming
(someone who texted it). An
action written for one surface can say so — “leads that did not come from a
landing page form” is a skip_when the judge can decide from that column
alone. Rows captured before the column existed carry a blank source, so a rule
that must not act on them should say that rather than assume a value.
Applying one to the rows already there
Not acting on a dataset’s history is the right default — declaring an action must not quietly spend a paid lookup on every row already in the table. But it makes a declaration that was wrong permanent: every row it decided about is remembered as decided, so fixing the steps helps the next row and none of the ones already handled.backfill is how you say “and do the ones you got wrong”.
"all" takes every row in the dataset. An RFC 3339 timestamp — "2026-07-01T00:00:00Z"
— takes every row that arrived at or after that moment, including one stamped
exactly at it. Omit the field and nothing historical is touched.
A backfill re-invokes the steps for each row in range, including rows this
declaration has already acted on, because reconsidering them is the entire
request. Every action they invoke is paid for again.
It does not all happen in one run. A run acts on a bounded number of rows and
makes a bounded number of invocations, which is what stops a backfill becoming
hundreds of external calls at once — so a large one is worked through over
consecutive runs, each picking up where the last got to, until the declaration is
level with the dataset and goes back to acting on what arrives. You do not have to
do anything to keep it moving. The run summaries say how many rows are left.
Editing a declaration is not a backfill. Changing a step rewrites the automation
and leaves its position alone, so an ordinary edit never replays the dataset.
Several per dataset
The name is what lets one dataset carry more than one declaration. A leads dataset can alert the sales desk and enrich each record, as two independent declarations that neither replace nor interfere with each other. The name is also the handle you use to change or remove one later, and it is how each appears in your automation list.Steps run in order
Each new row is taken through the steps in order, and each step sees what the ones before it produced. That is what lets a declaration look a lead up and then email the desk about them, with the email saying what the lookup found. A step is either an action to invoke or a script to run — never both.Alternatives, with fallback
Mark a step "fallback": true and it runs only when nothing has answered yet.
That is how “look this person up in the contact database, and web-search them
when it has never heard of them” is expressed: the search costs nothing when the
lookup already answered, because it never runs.
Without the flag, steps are a sequence rather than a set of alternatives. Use the
flag for the second way of getting the same answer; leave it off for the next
thing to do with the answer you have.
found_when — did it answer?
An action that finds nothing rarely returns nothing. It returns an envelope
saying so, and Erdo appends ok: true to every native action’s result. A contact
lookup that has never heard of an address replies:
found_when is a dotted path into the result
that decides the question: "found_when": "found" reads the miss for what it is.
This is deliberately separate from result_path, which decides what
{{result}} records. They were once the same knob, and restoring the
fall-through then meant pointing it at a scalar and throwing away the employer,
title and profile URL you called the action for.
result_path
Many actions wrap their answer in an envelope that echoes the request.
result_path selects the part worth recording — and becomes what {{result}}
refers to, which usually makes the destination templates shorter too. When no
found_when is given, it is also what decides whether the step answered.
for_each — one action, several times
Give a step a for_each list — a JSON array, or one {{placeholder}} resolving
to one — and the action is invoked once per element, with {{item}} and
{{item_index}} available in its input. Each invocation counts against the run’s
budget.
Steps that are scripts
Some decisions cannot be written as a template. Give a step ascript instead of
an app and key, and it runs as JavaScript against the row:
Templates
Any string in a step’sinput, and every value in destination.columns, may
carry {{placeholder}} references.
answered_by is how a destination records provenance in a multi-step
waterfall: a column mapped to {{answered_by.action}} says whether a row came
from the contact database or the web search behind it, and stays blank on a
lookup that found nothing. answered_by.app, answered_by.key and
answered_by.step (the step’s index) are also available separately. It names
the action step whose answer {{result}} currently carries, so a script
discarding a finding clears it, and a fallback that then answers becomes the
provenance instead.
The engine is deliberately small: dotted paths, and nothing else. Anything it
cannot resolve becomes blank rather than an error, because a declaration
written against one row shape will meet rows that lack a column — and one missing
column must not stop the run for every other row.
A value that is exactly one placeholder keeps the resolved type, so
"num_results": "{{result.count}}" reaches the action as a number rather than
the string "3". Anything else is text substitution. Values that are not strings
— "num_results": 3 — are settings you wrote, and pass through untouched.
Required and optional placeholders
A step whose templated input resolves to nothing cannot be built for that row, so it is skipped and the next step is tried. That is how a declaration handles a row with no email at all, and it is usually what you want. Write{{?path}} when the value is decoration rather than a requirement:
Ordered steps, and what one row costs
Actions cost money and scripts do not, which is the whole reason the two are treated differently. A fallback exists so you do not pay twice for the same answer; a script runs every time because it costs nothing to ask it.The destination
Give adestination to record what came back. Omit it entirely for an action
whose effect is the action — sending an email, filing a ticket.
key_column makes the write an upsert on that column, so acting on the same
subject twice replaces what was known rather than appending a rival copy of it. A
row whose key renders empty is not written: there would be nothing to identify it
by, and every such row would collide with every other one.
The destination must be a different dataset from the source. Writing back into
the source would trigger the automation again, and a keyed write would match each
source row and overwrite it with a partial one — erasing the captured columns your
result does not carry forward.
A run that found nothing still writes its row. “We looked and found nothing” is a
different and useful fact from “we never looked” — the first says this subject has
nothing worth surfacing, the second says nothing has reached them yet — and a
surface can only tell them apart if the empty result is recorded.
A row written twice
Datasets that receive leads are upserted, not appended: a landing page that captures an address first and the rest of the form a minute later writes the same row twice. Arrival time alone cannot tell that from a new row — the second write moves it forward — so a declaration would invoke its actions a second time for a subject it has already acted on, and pay for them a second time. 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 declaration 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 dealt with is one it has already decided
about.
On top of that it remembers what it has acted on: the value in the row’s
identifying column, and a fingerprint of what the row said. That is what covers
rows written before erdo_created_at existed, and it is what act below is
decided from. A row recognised either way is left alone and counted under
repeats. Erdo detects the identifying column the same way it detects the
arrival column — by probing what the newest row actually carries, since a column
can exist and be empty on nearly every row — and key_column overrides it.
Rows identified by email or phone
A dataset can contain people who supplied only a phone number. Declare the existing email identity and its fallback explicitly:dataset, name, and steps when saving it. The first nonblank source column
wins. Values keep their original spelling, so email identities stay the same
and a phone-only row can be recorded even when its lookup found nothing.
A row with every identity column blank is counted as unkeyed and cannot be
written to a keyed destination. Normalize contact spellings in the source
writer if different spellings must represent one identity.
A notification configured with after inherits the lookup’s source identity
and can therefore wait for phone-only results too. Another row action using
after must use the same primary column and exactly the same ordered fallback
columns. Its destination can join through {{row_key}} without
pretending a phone number is an email address. {{row_key}} never authorizes an
email recipient; an autoreply still addresses {{row.email}}. Notification
readback reports its resolved key_column, key_fallback_columns, and
after_key_column, so clients can detect a dependency whose destination key
changed and save the notification to bind it again.
For an existing destination, add and populate lead_key from its existing
identity values before changing its upsert key. Updating a declaration does
not migrate historical rows or request paid lookups; use an explicit backfill
only for rows you intend to look up again.
on_update decides what a second write means:
Even under
act, a rewrite that says exactly what the last one said is ignored:
that is not new information, and a writer replaying a batch must not become a
second bill. Choose act where a later write is what makes the actions able to
answer at all — a form’s first write may carry no name, which is exactly when a
contact lookup has nothing to check its match against.
A row with nothing identifying it is always acted on. Acting twice is bad;
never acting is worse.
What one run does
A declaration may list up to 5 steps. Each run acts on at most 10 new rows and invokes at most 10 actions, whichever binds first. That keeps an import from becoming hundreds of paid calls in one pass. The watermark still advances past everything that arrived, so a burst larger than the cap has its excess left rather than retried forever, and the run result reports the count undernot_processed rather than claiming a clean
success.
A single failed step is ordinary — a rate limit, a transient upstream error
— and never abandons the other rows in the same run. But a run in which every
invocation failed is not one flaky call: it is a declaration that cannot work, so
the run fails. That both makes it visible in your automation history and leaves
the watermark where it was, so those rows are still waiting once you fix the
cause.
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 declare the row action. If your dataset names it something
unconventional, pass timestamp_column explicitly.
Detection happens at declaration 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 acts on anything.
Whichever column you name, it has to hold timestamps. The declaration 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. Erdo checks what the column actually holds and refuses the declaration
when it is not a timestamp, naming the value it found.
An existing declaration is checked the same way as it runs, on both the point it
is starting from and the one it is about to record. Re-declare it against a column
that records arrival and it recovers by itself on its next run: the unusable
starting point is dropped and the declaration resumes from where the dataset now
stands, without replaying history. Until then its runs fail, naming the column and
the value — a run that fails invokes nothing and leaves the starting point where
it was, so the rows it could not order are still waiting once the column is
corrected.
Cost
Withoutskip_when, nothing per run. The automation is a deterministic script
fired by an event trigger on the dataset, with no polling loop burning tokens to
ask whether anything changed. It runs when a row lands and is otherwise idle;
the only metered work is the action itself.
With skip_when, each eligible row gets one small LLM judgement before its
steps. Rows the rule skips do not invoke those steps, which is particularly
useful when the actions are paid.
A backfill is the exception worth
knowing about, and only because of what the steps do: it invokes them once per row
in range, so a metered lookup over a thousand historical rows is a thousand
lookups. The runs themselves stay negligible.
Actions that need approval
Some actions require a standing approval before an unattended automation may invoke them. Grant one by running the action once in a thread and choosing always allow; without it, the invocation fails and — since that failure affects every row — the run goes red rather than passing quietly.Related
Arrival notifications
The purpose-built version of “email these people when rows land”.
Automations
The run history of the automation behind a row action.
Approvals
Standing policies that let an automation invoke an action unattended.
Datasets
Where the rows land, and where results are written.

