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.
Declaring 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.
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.
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. A declaration therefore remembers what it has acted on: the value in the row’s identifying column, and a fingerprint of what the row said. A row it recognises is left alone and counted underrepeats. 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.
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.
Cost
Nothing per run. The automation is a deterministic script fired by an event trigger on the dataset — no LLM, and no polling loop burning tokens to ask whether anything changed. It runs when a row lands and is otherwise idle. The only thing that costs anything is the action itself.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.

