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

# Scheduled actions

> Declare reads and judgements to run on a clock, bounded by the provider objects a workstream governs — never a hand-authored heartbeat script.

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](/dataset-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:

```json theme={null}
{
  "error": "step \"pacing\" reads steps.budgets in its script, but the steps declared before it are performance. A step that reads one that never ran evaluates to nothing without failing, which is how a threshold stops being checked without anybody noticing."
}
```

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`](/strategies#declare-which-provider-campaigns-a-workstream-governs)
— 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:

```json theme={null}
{
  "summary": "daily-optimizer: nothing in scope — maurice-newdev-optimization governs no provider objects, so no read was run.",
  "in_scope": 0,
  "steps_run": 0,
  "rows_written": 0
}
```

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.

```bash theme={null}
curl -X PUT "https://api.erdo.ai/v1/scheduled-actions" \
  -H "Authorization: Bearer $ERDO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "daily-optimizer",
        "workstream": "2200brickell-search-optimization",
        "schedule": "0 8 * * *",
        "timezone": "America/New_York",
        "steps": [
          { "name": "performance", "app": "google_ads", "key": "query_google_ads",
            "input": {
              "customer_id": "8834039525",
              "query": "SELECT campaign.id, campaign.name, metrics.cost_micros, metrics.conversions FROM campaign WHERE campaign.id IN ({{scope.ids}}) AND segments.date DURING YESTERDAY"
            },
            "result_path": "results" },
          { "name": "budgets", "app": "google_ads", "key": "query_google_ads",
            "input": {
              "customer_id": "8834039525",
              "query": "SELECT campaign.id, campaign_budget.amount_micros FROM campaign_budget WHERE campaign.id IN ({{scope.ids}})"
            },
            "result_path": "results" },
          { "name": "pacing",
            "script": "var findings = []; var budgetByCampaign = {}; steps.budgets.forEach(function (b) { budgetByCampaign[b[\"campaign.id\"]] = b[\"campaign_budget.amount_micros\"] / 1e6; }); steps.performance.forEach(function (p) { var budget = budgetByCampaign[p[\"campaign.id\"]]; var spend = p[\"metrics.cost_micros\"] / 1e6; var ratio = budget ? spend / budget : null; if (ratio !== null && ratio < 0.5) { findings.push({ campaign_id: p[\"campaign.id\"], campaign_name: p[\"campaign.name\"], spend: spend, budget: budget, ratio: ratio, issue: \"underpacing\" }); } }); return findings;" }
        ],
        "destination": {
          "dataset": "2200-brickell.optimizer-findings",
          "key_column": "campaign_id",
          "columns": {
            "campaign_id":   "{{item.campaign_id}}",
            "campaign_name": "{{item.campaign_name}}",
            "issue":         "{{item.issue}}",
            "pacing_ratio":  "{{item.ratio}}",
            "workstream":    "{{scope.workstream}}",
            "checked_at":    "{{now}}"
          }
        }
      }'
```

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.

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

## Related

<CardGroup cols={2}>
  <Card title="Row actions" href="/dataset-row-actions">
    The event-triggered counterpart — work that starts when rows land, not
    when the clock strikes.
  </Card>

  <Card title="Strategies" href="/strategies#declare-which-provider-campaigns-a-workstream-governs">
    How a workstream declares the provider campaigns it governs — the
    `external_refs` a scheduled action's scope is read from.
  </Card>

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

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