Skip to main content
Some work doesn’t start because a row landed. It starts because it’s eight in the morning: a daily read of an ad account’s spend, a weekly pacing check against a budget, a conversion reconciliation before anyone is at their desk. A scheduled action declares exactly that — the reads to make, what to do with what they returned, and where to put the answer — and Erdo owns everything underneath: the automation, its trigger, and the schedule. Row actions answer the same question for work that starts when data arrives. This is the other half, for work that starts because of the clock. The two share a step vocabulary on purpose — an author who has declared one can read the other — but they differ in two places the trigger changes the meaning, and both differences matter more than they look.

Every step runs

A row action is a chain of alternatives: the first one to answer wins, because looking a person up in a second place is pointless once the first place found them. A scheduled action is the opposite. It’s usually several queries whose results are all needed — spend from one read, budget from another, a threshold that compares the two — and a step that stopped running because an earlier one succeeded would silently remove a threshold’s only source. So every step in a scheduled action is named, every one of them runs, in the order declared, and each one’s result is kept under its name — steps.performance, steps.budgets — for the steps that come after it. That naming is checked, not just conventional. Every steps.<name> a script mentions must name a step declared before it in the same declaration — checked when you PUT the declaration, not discovered the first time it runs. This exists because it already went wrong quietly: three thresholds — budget pacing and two impression-share checks — sat in a live, hand-authored record for weeks with no query supplying them. Nothing failed. Nothing looked broken. The script read steps.budgets, got undefined, compared a number against it, and concluded nothing, every single morning, until an audit noticed. Declaring the same thing here rejects it before it can run once:
The same check catches the more realistic version of the mistake: the declaration was correct, and later somebody edited it and deleted the read a threshold depended on. That edit fails to save, rather than shipping a threshold that silently stopped being checked.

Scope comes from the workstream, read fresh every run

A scheduled action names a workstream, and what that workstream currently governs — its external_refs — is what bounds the run. Nothing about that scope is copied into the declaration. Every run reads the workstream’s refs as of that run, and hands them to the steps as {{scope.ids}} (a flat list of provider ids, the shape almost every query wants as the right-hand side of an IN (...)) and {{scope.refs}} (the full objects, for when a step needs more than the id). This is the entire reason the resource exists. What it replaces is a campaign allowlist typed into a knowledge record — the record itself called it “a manually maintained mirror” of a mapping the job “cannot read.” It drifted exactly the way a hand-kept mirror does: it kept driving recommendations for a campaign that had been wound down eight days earlier, and a discovery rule written to route around that problem pulled a different property living in the same ad account into scope instead. One Google Ads account has held two separate developments at once — an unfiltered read of that account, or a stale one, reaches the other customer’s campaigns. Reading the refs fresh at run time closes that gap by construction. Add a campaign, or a whole development, to a workstream’s scope, and the very next run picks it up — no edit to the declaration, nothing redeployed. Remove one, and the next run stops reading it. The mapping lives in exactly one place.

An empty scope means no run, not an unfiltered one

A workstream that governs nothing yet — a campaign whose provider ids haven’t resolved, a development just getting started — is a legitimate, honest state. When that’s what a run reads, the run does no work and says so:
It does not fall back to running its queries with the filter removed. That fallback is the actual danger being defended against: a GAQL query with campaign.id IN ({{scope.ids}}) rendered against an empty list is not “match everything,” it’s a query that would either fail or, worse, silently match nothing — but a step that instead dropped the filter because scope came back empty would read the whole account, including whatever else lives in it. So scope is checked before a single step runs, and reading it at all is itself load-bearing: a workstream that names a scope and can’t be read fails the run outright rather than proceeding as if it had none. A declaration can omit workstream entirely, but only for one that genuinely acts on no provider objects — a run that reconciles something org-wide, say. For anything reading a provider account, name the workstream.

Declaring one

PUT /v1/scheduled-actions takes a name, a workstream, a cron schedule, and the steps. Like a row action, the declaration replaces whatever was previously declared under the same name.
Two query_google_ads reads gather yesterday’s spend and each campaign’s budget, both filtered to the campaigns 2200brickell-search-optimization governs today — never a list written into the declaration. The pacing step is a script: it joins the two reads by campaign id and decides, in code, which campaigns are underpacing. That decision — join two result sets, compute a ratio, apply a threshold — has no template that expresses it; a script is what a declaration reaches for once a step needs to reason about what an earlier step returned rather than just relay it. GET /v1/scheduled-actions?name=<name> reads one declaration back — its steps, schedule, workstream, and the automation’s job id, so you can pull its run history through the automation surfaces. GET /v1/scheduled-actions lists every declaration in your organization. Both read state off the automation itself, never a second copy. Passing "enabled": false removes the declaration, the same as a row action and for the same reason: a disabled automation would keep the state its last run left, and re-enabling it months later would resume from a position describing a world that has moved on. There is no pause — declare it again with the schedule you want when you’re ready to resume.

The schedule

schedule is a five-field cron expression — "0 8 * * *" for every day at eight. timezone is the IANA zone it’s read in, defaulting to UTC. Set it explicitly for anything comparing against a provider’s own day boundary: an ad account reports “yesterday” in the account’s own timezone, and a run fired at 08:00 UTC with no timezone set is still the previous afternoon in Miami — a pacing check reading “yesterday’s spend” would be reading the wrong day. Updating a declaration’s schedule replaces the trigger, not just the stored value — an edit that changed the time and left the old trigger running would be the sort of half-applied change nobody would think to look for.

Steps

A step is an action to invoke (app + key) or a script, never both — the same rule as a row action. Everything a row-action step supports on the action side carries over here: input with {{placeholder}} templates, result_path to select what an action’s envelope actually contains, and for_each to fan one action out over a list (most usefully {{scope.refs}}, one invocation per governed object). What’s different is the vocabulary a script sees. A row-action script judges one row; a scheduled-action script receives (scope, steps, run) — the objects this run may act on, every prior step’s result addressed by name, and {now, budget, invocations} describing the run so far. It shapes what the reads returned: it cannot invoke an action or read a dataset directly, because the declaration itself has to say what it calls and where it writes — that’s what makes “what can this automation touch” something you can read off the declaration instead of something you’d have to read the generated code to know.

The destination

destination records what a run produced, the same shape as a row action’s: a dataset, an optional key_column to upsert on, and columns mapping each column to a template. The one behavior specific to scheduled actions: when the last step returns a list, the run writes one row per element. A pacing check that found three underpacing campaigns writes three rows, each with its own {{item}} bound to one finding — that’s how a set of things worth looking at becomes a set of rows someone can filter and sort, rather than one blob a person has to parse. Any other shape — the last step returned a single object, or nothing — writes at most one row. Omit destination entirely for a declaration whose steps act rather than report (pausing a campaign, sending an alert) — there’s nothing to record. Templates in destination.columns resolve against {{scope...}}, {{steps.<name>...}}, {{result...}} (the last step’s value), {{item}} and {{item_index}} when writing one row per list element, and {{now}}.

Limits

A declaration may list up to 12 steps. One run makes at most 10 action invocations across all of them — the same budget row actions share, so a for_each over a large scope is the thing to narrow rather than the step count. A run writes at most 200 rows; anything beyond that is reported as not recorded rather than silently dropped. A script step is capped at 20,000 characters, and a declaration name at 60. A run in which every invocation failed doesn’t complete quietly — it fails outright, the same reasoning as a row action: reporting success while nothing actually worked would leave a broken declaration green in every automation view while it does nothing every morning.

No MCP write tool, deliberately

Reading a scheduled action’s declaration is available as an MCP tool (erdo_get_scheduled_actions), so an agent can see what’s already scheduled and what it’s bound to. Declaring one is REST-only. There is no erdo_set_scheduled_action tool, and that asymmetry is the point: the entire safety property this resource provides is that a run’s scope comes from the workstream, not from anything the automation itself can ask for. An agent that could declare its own scheduled work could hand that work a wider scope than it was ever given — which is exactly the failure moving scope out of hand-authored prose was meant to remove. Declaring a scheduled action is something the system that holds the evidence for a workstream’s scope does, over /v1, the same way a workstream’s external_refs are written.

Cost

Nothing to declare or hold idle. The automation is a deterministic script fired by a cron trigger — no LLM, and no agent reasoning about whether to run. The reads and any action a step invokes are what cost anything, and only when the schedule fires.
Don’t hand-author this as a recurring agent or a polling heartbeat instead. A five-minute heartbeat doing a job like this once cost a customer roughly $81 in a single week to check a couple of numbers once a day. A scheduled action runs only on the cadence you declared, at no per-tick cost beyond the reads it actually makes.

Row actions

The event-triggered counterpart — work that starts when rows land, not when the clock strikes.

Strategies

How a workstream declares the provider campaigns it governs — the external_refs a scheduled action’s scope is read from.

Automations

The run history of the automation behind a scheduled action.

Approvals

Standing policies that let an automation invoke an action unattended.