Skip to main content
Data arriving is usually the start of something, not the end of it. A lead lands and somebody should hear about it. A lead lands and you want to know who they are before you call back. A support ticket lands and it should be filed somewhere else. All three are the same shape: when rows land in this dataset, do this to each one. A row action declares exactly that. You name the dataset, an ordered list of steps — actions to invoke, or code to run — and, optionally, where to record what came back. Erdo owns everything underneath: the automation, its trigger, the watermark separating new rows from old ones, and the bookkeeping that stops a burst being processed twice. There is no script to write and nothing to keep in sync.

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:
Left alone that reads as an answer, so the fallback behind it is unreachable and you pay the lookup on every row. 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 a script instead of an app and key, and it runs as JavaScript against the row:
What it returns becomes that step’s result. A script always runs — judging what the steps before it found costs nothing — and returning nothing clears the answer, so the next fallback is tried instead of a doubtful finding being recorded. That is what the example above is for. A contact lookup matches on the email address and returns whoever it has under it, with no signal about whether that is the right person: the confidence fields come back null on a good match and a wrong one alike, and the response shape is identical either way. Nothing in the payload can gate it. The only evidence available is what the lead themselves typed, and comparing the two is conditional work — share a name word or discard the finding, and compose the summary without a dangling connective when only one side is known. No template expresses that, and the alternative is hand-writing the whole automation. A script may also return a note explaining its decision. Notes appear on the run result and never count as an answer, so an explained rejection is not recorded as a match. A script shapes data. It cannot invoke actions or read datasets — those are the two things the declaration itself is supposed to state, so that what an automation touches can be read off it rather than found by reading code.

Templates

Any string in a step’s input, 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:
The step still runs when the optional value is missing. This matters more than it looks: an alert must never be conditional on an enrichment that happened to find nothing. The email was always going to be sent, and what was looked up only makes it better.

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 a destination 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 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. 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 under not_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.
Don’t build this with a recurring agent or a polling heartbeat. A five-minute heartbeat doing a job like this once cost a customer roughly $81 in a single week to notice one or two rows a day — while also being up to five minutes slower than the event trigger.

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.

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.