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.
If a provider failure is followed by a managed receipt reconciliation refusal, the run keeps the current refusal in last_error and records the original action and observed HTTP status in bounded diagnostic notes, when available. These notes omit provider response bodies and lead values. A successful fallback clears its recovered failure. An HTTP status, including 404, does not prove that a paid lookup was uncharged and never authorizes an automatic retry. An unresolved paid attempt remains manual reconciliation work for that lead. It does not stop independently verified new leads from being processed: a new purchase must belong to a live-captured canonical lead created after both the receipt cutover and the earliest unresolved ordinary chunk, across every declaration generation. Historical or adopted identities still require explicit recovery. Exact completed receipts are reused without another provider call. The original unresolved receipts, chunk cursors, and manual counts remain retained; continuing new arrivals does not mark those earlier attempts successful. A missing or malformed quarantine cursor stops traversal visibly. Carried quarantine state is limited to 2,048 blocked keys and 1 MiB of serialized cursor state. Reaching either limit requires explicit repair before traversal continues; no retained receipt or historical chunk is pruned to fit the limit. 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 stops a declaration. For a declaration that has used outcome_contract: "lookup-v1", this disables its automation and triggers and withdraws standing grants while preserving lookup history, provider receipts, and resolution tools. It remains readable with enabled: false, and unresolved outcomes can still receive an audited disposition. Removing the public outcome contract later does not remove this retention. Other declarations are removed. Re-enabling a retained lookup reuses the same automation and starts from the current arrival and update cursors. Rows and updates that arrived while it was off are not automatically purchased; use the counted recovery or explicit backfill flow when historical work is intended. A run already executing when disabled may finish. Deleting the automation directly remains a destructive operation that removes its lookup history; use enabled: false to retain it.

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:
  • operation is create, update, disable, remove, or no_op.
  • backfill_requested says this request explicitly asks to replay history.
  • backfill_in_progress says an earlier replay still has historical rows left.
  • pending_run says an already-queued run or dataset debounce exists.
  • would_run_now says the write would start a run immediately.
  • would_contact_historical_rows is the direct safety check for an outreach declaration: it is true for a requested or unfinished backfill.
The 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. The lead reference in the follow-up-page URL is separate and only personalizes and attributes the link: write {{?lead_ref}}, which is the lead’s permanent reference on a dataset enrolled in lead identity and blank on one that is not. 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 script that builds a link itself reads the row’s lead reference as run.lead_ref, since text a script returns is not searched for placeholders. 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.
The row is a list of {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_to is the only address it can ever reach. Whatever the declaration’s own to renders to — the sample row’s address, a literal, a placeholder you mistyped — it is replaced with deliver_to before 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 script steps followed by exactly one erdo/send_email step. 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 with PUT /v1/dataset-row-actions and test-send the message part on its own.
The response says what happened:
A row that cannot fill the message is not an error. When a placeholder that is the whole of a required field resolves to nothing — leaving that field empty — nothing is sent and the answer names the field:
That is exactly what a real row missing the same value would do — the step is skipped rather than sent — so it is usually the most useful thing a test can tell you. Write {{?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 from hello@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. Generic declarations are deleted when stopped. Lookup declarations preserve their owner and evidence, then refresh their arrival and update cursors when re-enabled. Both paths avoid automatically invoking a paid action for everything that accumulated while the declaration was off. Ask for a backfill when you want the history done.

Skipping rows before any action runs

Use skip_when when some new rows must not reach this declaration at all:
The value is your policy, in your own words. Erdo judges each new row against it before invoking a step or writing a destination row. A positive judgement is a completed decision: its reason appears as a note in the automation run, and the watermark advances so the row is not reconsidered on the next run. The judgement is conservative and fails open. If it errors, the row goes through the declared steps exactly as it would without 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:
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. 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. trail records the rest of that story: which steps ran before the one that answered, what each was asked, and how each came out. It is one JSON string, so a destination column mapped to {{trail}} stores it whole. It holds one entry per declared step, in order, including the steps that never ran: When a lookup names the wrong person, the trail says which step and which vendor endpoint produced the match, without paying to run the lookup again. 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. The write settles it. Every row records the moment the dataset created it, in a column named erdo_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:
This is the identity and destination portion of a declaration; include its 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 under not_processed rather than claiming a clean success. A failed row does not abandon the other rows in the same run. The run stores failed identity keys separately from its advancing arrival cursor. Each later scheduled or write-triggered execution selects one safely retryable queued identity and leaves row capacity for new arrivals. Unknown provider outcomes are manual work and do not enter that purchase queue. Retry and fresh work alternate priority so a retry that consumes the whole action budget cannot monopolize later triggers. Thus a permanent provider error cannot block new leads. Queued keys rotate fairly and bypass the creation watermark. When the retry budget leaves fresh arrivals beyond the cap, the arrival cursor drains them using timestamp and identity together, including peers with identical timestamps. Anonymous peers at such a boundary require explicit repair because they cannot be resumed safely. After a run result is persisted, successful peers are not replayed to retry a failure, including successful rows without identity keys. Managed action steps save an encrypted provider-result receipt before returning the result to the script. If a destination write or execution checkpoint then fails, the next run reuses the saved output after checking current access and approval. It does not buy that action again for the same generation, row, step and input. A meaningful changed input can run under the declaration’s existing on_update: "act" policy. During a receipt-system rollout, a temporary setup failure preserves arrival progress and the existing retry queue, then resumes once activation completes. It does not charge the provider or turn the setup failure into a manual reconciliation requirement. If a trusted authorization or identity check stops an action before the provider is invoked, the refusal remains retryable. Restoring authorization or correcting the declaration lets a later run check it again. This is different from a provider HTTP error or timeout, which cannot prove that no lookup happened. An explicitly approved historical recovery also restores its unused call allowance only for a proven pre-provider refusal. An enabled row-action declaration establishes its durable receipt boundary before its first scheduled execution. A lead captured after the declaration is active is not mistaken for history merely because the job has not run yet. Saving or enabling the declaration preserves any existing boundary and does not purchase lookups. An older declaration that names its source by slug can acquire that same source’s verified UUID during a save. This requires a matching existing source-event trigger and unchanged identity keys; the receipt boundary and saved provider results stay intact. A different source or identity selector still requires explicit reconciliation. Fresh-lead admission requires trusted live-capture creation records for the complete canonical identity family; assigning identities to historical rows does not make those rows new or authorize a lookup. A started provider attempt with no saved result has an unknown outcome. That includes a timeout or a failure while saving the output. It requires explicit reconciliation and appears as manual_retry_required, not an automatic retry queue. Anonymous action rows are refused before a provider call because they cannot have a stable receipt. Historical failed rows without receipts are also held for reconciliation; installing this protection never replays them. Immediate continuations drain untouched arrivals or an authorized backfill without retrying failed identities. Provider failures alone never request an immediate continuation loop. A run in which every row fails still persists its failure queue. Its job transport completes to save that checkpoint, while last_execution.status reports incomplete; it never reports a clean processing outcome. A failed digest retains its complete timestamp window and retries that window on a later scheduled execution, preserving its total and bounded preview. It does not turn into a one-lead alert. Later arrivals remain outside the retried window and are processed afterward. The window is a timestamp range, not a frozen snapshot: rows newly visible at the same timestamp are included when that range is re-read. Immediate continuations never retry the failed shared window. Older failed digests without complete window bounds, and a mixed window containing a successful paid effect without a stable row identity, require explicit repair; the system does not guess the missing window. The queue holds at most 500 identities. Failed rows without an identity and failures beyond that bound require an explicit repair: the result retains manual_retry_required and unresolved_failed_rows, including on quiet runs. No failed identity is silently discarded as successful. Explicitly requesting a backfill starts a new generation and may purchase historical actions again. A new generation does not clear an unresolved provider attempt or historical unknown outcome; those identities still require explicit reconciliation. GET /v1/dataset-row-actions includes a small last_execution view when a run exists: status, started_at, completed_at, failed_rows, pending_rows, pending_retry_rows, unresolved_failed_rows, degraded_rows and manual_retry_required. Its status is incomplete when a completed job reports failures, pending work, or degraded rows; an enabled declaration and a completed job alone do not establish successful processing. The view contains no row identities, provider responses or raw error messages. Use the automation history for the run’s diagnostic summary. degraded_rows counts processed rows whose final answer is empty after an action step failed. A fallback that returns no match, or a result subsequently set aside by a script, does not erase that failure. A reliable recovered answer is not degraded. These rows are complete for scheduling: they are recorded as processed, not queued for retry, and the diagnostic never purchases another lookup. Original failure details remain in the operator execution notes. This count describes that execution only; a later quiet execution does not retain a historical backlog.

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

Without skip_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.
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.
Unknown action outcomes remain visible across backfill generations and lead merges. Correcting an email or phone does not authorize another purchase. A merged lead retains the unresolved effects of its absorbed identities. Standing approval is checked again before a new provider call after identity verification. Row actions run their saved declaration. To change the source or steps, save the declaration before running it; per-run parameter overrides are refused.

Recover selected historical leads

A provider can complete a lookup even when its response or the destination write fails. An uncertain attempt therefore requires an explicit decision before another purchase. Saving settings and normal retries never authorize that purchase. Use the same declaration endpoint to preview between 1 and 100 currently eligible canonical lead identities for an existing saved declaration:
The recovery response contains preview_token, selected_rows, maximum_provider_calls, and action_call_ceilings for each provider action. The call ceiling includes possible fallback steps and up to 100 calls per dynamic fanout step per selected row. Selected rows and possible paid calls are different counts; a historical missing result does not prove its previous lookup was unpaid. The preview performs no provider calls. These are conservative provider-action invocation bounds, not prices, predicted charges, or limits on the vendor HTTP requests made inside an action. One action may perform multiple vendor lookups. Recovery reuses a previously completed step when its exact inputs, declaration generation and current lead identity family match; reused output consumes no provider-call allowance. Dependent inputs may only become known during execution, so the preview does not subtract assumed reuse. Unusable completed output requires reconciliation rather than an automatic repeat purchase. After reviewing these counts, explicitly authorize possible repeat purchases:
Confirmation freezes the selected source values and saved recipe. Source, ownership, or recipe changes invalidate it. Repeating the same confirmation joins the same execution; a destination write retry reuses completed provider outputs. A new unknown provider outcome remains reconciliation_required and cannot be purchased again by retrying that token. A fresh preview and explicit confirmation are required for any further purchase decision. Previous uncertain outcomes remain in the audit record. Normal processing keeps its original cursor and seen rows. Existing skip policy still applies to selected rows. Rows intentionally skipped are not automatically included in a recovery selection. No prices are inferred from the call ceiling; each connected provider’s billing terms still apply. Recovery execution results include managed_recovery_outcome; a completed script transport can still require reconciliation. Historical recovery has its own outcome and does not mark the ordinary automation healthy, settle its repair, change its normal failure streak, or replace its normal progress checkpoint. Cancelling a historical recovery requests that its executor stop admitting more work. The execution remains active until that executor acknowledges termination; confirming again during that interval joins it instead of starting another run. Any provider response already purchased is retained for a later writeback retry. If termination cannot be established after a process failure, recovery reports that reconciliation is required rather than purchasing the same result again.

Canonical lead updates

Canonical lead capture keeps first arrival separate from later writes. Its optional internal erdo_updated_at field is platform-owned and declared with the lead contract; caller timestamps cannot replace it. Existing incompatible definitions are refused without rewriting the contract. An ordinary dataset’s business column with this name keeps its ordinary meaning. An update-aware action resolves canonical enrollment from the dataset owner when saved and refreshed. It advances a bounded timestamp-and-identity cursor independently of arrival progress, alternating the two streams when both have work. A large update backlog therefore cannot hold new leads behind it. Failed identities remain queued, and a failed shared window retains its original stream and bounds; unknown provider outcomes still require explicit reconciliation. Changing on_update from skip to act starts future update processing at the current write cutoff. Editing an already update-aware action preserves its existing progress. Neither operation requests historical paid execution. Canonical lead notifications use the same ownership check to keep the internal update cursor out of email answers and duplicate fingerprints. An ordinary business column with the same name remains visible. Re-ensuring an enrolled older lead contract adds the optional cursor under the contract transaction; a conflicting producer declaration leaves the older contract unchanged. Failed arrival drains retain their arrival ordering and cannot advance the independent update cursor, including when a previous execution exhausted its budget before reaching a fresh row. Existing active declarations without an immutable receipt owner are initialized automatically in bounded, paginated startup work. Initialization does not run actions, clear failed keys, reset execution results, or advance an existing receipt boundary. A declaration with a still-running legacy execution remains uninitialized and visible in startup warnings; a subsequent startup retries it after that execution drains. Recovery may retain a useful partial destination row while a later action remains incomplete. The result reports recovery_incomplete_rows and safe recovery_incomplete_steps (app, action, step and a fixed reason), separately from unknown-provider-outcome counters. An unrecovered action refusal or a partially exhausted invocation budget requires reconciliation; a partial destination write does not certify completion. Completion requires an explicit zero incomplete-row count. Successful declared fallbacks and the recipe’s legitimate no-input, no-match, policy and conditional skips retain their existing meaning. Safe recovery step diagnostics may include an observed numeric http_status for the first provider failure. Later reconciliation refusals retain that status on the original recipe step; a successful declared fallback clears the recovered diagnostic. Recovery does not return raw provider errors or normal execution notes. Both recovery-intent and ordinary completed caches must decrypt to a usable JSON object. A null, malformed or unreadable saved output refuses recovery without falling through to another receipt or purchasing a replacement. A successful fallback clears incomplete steps only within its current action chain. A later independent lookup or its fallback cannot complete an earlier refused effect, even when the later lookup has no input or no connected app.

Durable lookup outcomes

Lookup declarations can opt into "outcome_contract": "lookup-v1". Map exactly one destination column to "{{outcome}}"; the value is a JSON scalar containing:
completion is complete, incomplete, or not_run. answer is matched, not_found, discarded, or none. These describe the final validated result and its actual lookup trail. A successful fallback can recover an earlier provider failure; an error in the winning step, including partial fan-out, remains incomplete. An empty result after a failed or disconnected source is incomplete. A searched no-match is complete only when the trail explicitly establishes it. A discarded candidate is complete only when the searches completed without errors. No search is not_run with answer none. The outcome is staged as unresolved before the destination write. It resolves a previous unresolved outcome only after the destination write succeeds and that execution is durably completed. A failed write or cancellation preserves staged lookup evidence without certifying that an accepted answer was saved. A blank destination key also stages an incomplete outcome, even when no row can be written. Completed paid work remains remembered, so this failure does not automatically repeat a provider call. In counted historical recovery, a blank destination key counts as one incomplete row and holds that chunk’s cursor. Earlier completed chunks remain completed; recovery cannot report success or continue past the unsaved row. Quiet runs leave unresolved outcomes intact. Older declarations and historical rows without this contract do not acquire invented completion evidence. GET /v1/dataset-row-actions returns unresolved_lookup_rows independently of last_execution. Add include_unresolved=true to return up to 100 safe summaries in unresolved_lookups, with outcome_id, authorized lead_ref, completion, answer, and observed_at. Provider payloads and raw errors are excluded. Existing resource permissions apply. A summary can describe a completed lookup while its destination write or terminal confirmation remains unresolved; completion alone does not certify persistence. The durable unresolved count is the health signal. An operator can record an audited disposition through the existing write endpoint:
Disposition requires edit access and records the authenticated actor and reason. It cannot modify the declaration or start provider calls. Unresolved outcomes also project a durable attention item into workstreams whose scope includes the source dataset. The notice clears when the ledger has no unresolved outcomes. Recovery continues to use the existing counted preview and explicit confirmation; neither maintenance nor saving this declaration purchases historical lookups. To inventory previously saved failures without purchasing lookups, send the same resource { "dataset": "my.leads", "name": "enrich", "action": "inventory_lookup" }. This requires the current lookup-v1 declaration and a keyed destination. It reads up to 200 distinct destination-key groups per call using an encrypted durable cursor. Duplicate keys are unverified and are never imported. The response’s lookup_inventory gives scanned, imported, unverified, and complete; repeat until complete is true. Only corroborated source rows with an explicit failed provider trail and provably empty mapped answer are imported as unresolved. Missing trails, ambiguous mappings and historical success are unverified. Inventory never marks a legacy row healthy or replaces an existing outcome, and invokes no providers. An interrupted page resumes from the last committed cursor. The existing maintenance pass also records an incomplete outcome when a verified provider receipt remains uncertain after its execution ends, including a crash during the provider call. It never repeats the provider call. Receipts from an older declaration or source binding cannot reopen current outcomes; a later accepted result or audited disposition keeps older uncertainty resolved.