> ## Documentation Index
> Fetch the complete documentation index at: https://docs.erdo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Row actions

> Declare what happens when new rows land in a dataset — invoke an action on each one, and record what came back.

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.

```bash theme={null}
curl -X PUT "https://api.erdo.ai/v1/dataset-row-actions" \
  -H "Authorization: Bearer $ERDO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "dataset": "2200-brickell.2200-brickell-leads",
        "name": "enrich",
        "steps": [
          { "app": "apollo", "key": "enrich_person",
            "input": { "email": "{{email}}" },
            "found_when": "found" },
          { "app": "erdo", "key": "web_search",
            "input": { "query": "{{name}}", "num_results": 3 },
            "result_path": "results.0",
            "fallback": true }
        ],
        "destination": {
          "dataset": "2200-brickell.lead-enrichment",
          "key_column": "lead_email",
          "columns": {
            "lead_email":  "{{email}}",
            "lead_name":   "{{name}}",
            "title":       "{{result.title}}",
            "employer":    "{{result.organization.name}}",
            "searched_at": "{{now}}"
          }
        }
      }'
```

`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.

<Note>
  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.
</Note>

## 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:

```json theme={null}
{ "found": false, "email": "someone@gmail.com", "ok": true }
```

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:

```js theme={null}
// (row, result, steps, run) are in scope.
var found = result || {};
var claimed = String(row.name || '').toLowerCase();
var got = String(found.name || '').toLowerCase();

var shared = claimed.split(/\s+/).some(function (w) {
  return w.length > 2 && got.indexOf(w) !== -1;
});
if (!shared) {
  return { note: 'discarded ' + found.name + ': shares no name word with ' + row.name };
}

var title = found.title, org = found.organization && found.organization.name;
if (title && org) { return { summary: title + ' at ' + org, corroborated: 'name' }; }
if (title)        { return { summary: title, corroborated: 'name' }; }
return { summary: org ? 'Works at ' + org : '', corroborated: 'name' };
```

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.

| What you write          | What it resolves to                                       |
| ----------------------- | --------------------------------------------------------- |
| `{{email}}`             | the row's `email` column                                  |
| `{{result.title}}`      | a dotted path into what the last step answered            |
| `{{result.hits.0.url}}` | the first element of a list                               |
| `{{steps.0.name}}`      | what a particular step returned                           |
| `{{item}}`              | the current element of a `for_each` list                  |
| `{{now}}`               | the current time, ISO 8601                                |
| `{{row.result}}`        | the row's own column, when it is called `result` or `now` |

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:

```json theme={null}
{ "subject": "New lead: {{name}}", "body": "{{?result.summary}}" }
```

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.

<Note>
  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.
</Note>

## 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:

| Value            | What happens                                                        |
| ---------------- | ------------------------------------------------------------------- |
| `skip` (default) | A row already acted on is left alone.                               |
| `act`            | A row whose **content changed** is taken through the actions again. |

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.

<Warning>
  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.
</Warning>

## 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

<CardGroup cols={2}>
  <Card title="Arrival notifications" href="/dataset-notifications">
    The purpose-built version of "email these people when rows land".
  </Card>

  <Card title="Automations" href="/automations">
    The run history of the automation behind a row action.
  </Card>

  <Card title="Approvals" href="/approvals">
    Standing policies that let an automation invoke an action unattended.
  </Card>

  <Card title="Datasets" href="/data">
    Where the rows land, and where results are written.
  </Card>
</CardGroup>
