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:
Scope comes from the workstream, read fresh every run
A scheduled action names aworkstream, 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: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.
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 afor_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.Related
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.

