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.
customer_id each step targets comes from GET /v1/ad-accounts —
address it to an operating account, never a manager (MCC). A manager holds no
campaigns of its own, so a query aimed at one returns empty rather than
failing, and nothing here would catch a pacing check reading that silence as
“nothing is underpacing” rather than “this queried the wrong account.”
Two query_google_ads reads gather yesterday’s spend and each campaign’s
budget, both filtered to the campaigns northline-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.
Running once, or stopping at a date
Two optional fields bound the schedule. Both take an RFC3339 instant.run_atmakes the declaration a one-off. The steps run once at or after that time, and never before it. After that one run the automation stops permanently.ends_atstops the schedule after that time. No run starts later.
run_at or ends_at clears it, and the declaration goes back to running on its
cron schedule with no bound.
Saving the same name again after its one-off has fired starts a new
declaration. With a new run_at it schedules a new one-off. Without bounds it
becomes an ordinary recurring schedule. The finished automation is removed
first, run history included, the same way a save with enabled: false removes
a declaration.
If the work belongs to a date, declare it with run_at. A daily schedule whose
first step checks whether the date has arrived runs every day until then, and
every run that finds nothing to do is still a run you pay for and a thread in
the feed.
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, failed, state} describing the run so far. A step that fans out keeps only
the results that came back, so failed names, per step, the for_each items
whose invocation did not return one (run.failed.<step>, absent when every
item succeeded). Check it before reading an empty result as “nothing found”: a
read that failed and a read that matched nothing look the same in steps. 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.
Each script step can keep JSON-serializable values in run.state. The next
completed execution receives those values under the same step name. A failed
step retains its previous state; step return values are not saved automatically.
This lets a monitor retain an incident’s original baseline across checks.
To raise something in the attention feed, end with a step that
invokes erdo / emit_attention_items. It takes items, a list of
escalations, and each item is keyed by dedupe_key. Filing a key that is already
open refreshes that one item. An item with resolved: true closes the item this
automation filed under that key once the condition has cleared. Filing needs a
title and at least one subjects entry naming what the item is about, such as
{"kind": "google_ads_campaign", "id": "<campaign id>", "label": "<name>"}. Give
the step for_each over a list holding the item list, so a run with nothing to
report makes no call at all.
A script cannot make HTTP requests. To check that URLs load, invoke erdo /
check_links with urls, a list of up to 50 URLs. For each one it follows
redirects and returns ok (a 2xx final answer), status, final_url,
other_host (the redirects ended on a different site) and error. A URL that
had not answered when the call ran out of time carries unchecked instead,
which means nothing is known about it either way. For an Erdo page it also
returns erdo_page: public is false when the page is private, deleted or not
served on that domain, same_organization is false when the page belongs to
another organization, and lookup_failed is true when the page could not be
looked up, so treat that URL as unchecked too.
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.

