Automations

An automation is a flow: one trigger, then a graph of steps that run when it fires. Flows live in the Automations tab of the dashboard, and the same flows are readable and writable through the REST API, the CLI, the SDK, and the MCP server.

A flow is a graph of nodes connected by ports. Every node has one or more named outputs (out, true, false, error, each), and an edge leaves a specific port. That is the whole model: what a step does is in the node, where the flow goes next is in the edges.

Triggers

A flow has exactly one trigger. The trigger decides when the flow runs and what variables the steps can read.

Workspace events

These fire the moment something happens in the workspace.

TriggerFires when
review.status_changedA creative moves to approved, changes requested, or in review
comment.addedSomeone comments on a creative, including guests on a review link
comment.resolvedA comment thread is resolved
creative.uploadedA new creative finishes uploading
creative.version_uploadedA new version is added to an existing creative
share_link.viewedA review link is opened
share_link.commentedSomeone comments through a review link
ad.publishedA creative is published to Meta or TikTok
ad.rejectedA platform rejects an ad, with the rejection reason on the payload
lead.receivedA Meta lead ad submission is pulled in
upload.failedA publish to a platform fails
member.joinedSomeone accepts a workspace invitation
test.startedA creative test starts
test.completedA creative test finishes
test.promotedA test winner is promoted
test.killedA test loser is stopped
creative.status_changedThe hourly performance rollup relabels a creative as a winner, loser, fatigued, or back to neutral
connection.lostA Meta or TikTok connection stops working
storage.thresholdWorkspace storage passes 80% or 100% of the storage your plan includes

Schedules, metrics, and manual runs

TriggerFires when
schedule.dailyOnce a day at the hour and minute you pick, in your time zone
schedule.weeklyOnce a week on the weekday you pick
schedule.monthlyOnce a month on the day you pick. A day past the end of a short month runs on its last day, so the 31st still runs in February
metricA recurring check of ad performance against your conditions, as often as every 15 minutes
budget.pacingA budget plan crosses your pace threshold
webhookA POST arrives at the flow's secret hook URL
manualNever on its own. You start it against a selection of creatives or ads

A schedule takes hour (0 to 23), minute (0 to 59), and timezone (an IANA name such as Europe/Helsinki); weekly adds weekday (0 is Sunday), monthly adds dayOfMonth (1 to 31). Schedules fire at most once per slot. If the workspace is unreachable when a slot passes, that slot is skipped rather than replayed later, so an outage costs one run and never produces a burst of catch-up runs.

creative.status_changed carries the creative, a performance object (the new and previous label, the fatigue reason, and the 30-day spend, impressions, clicks, ctr, frequency, purchases, purchase value, roas, and ad count), and ads, the live ads matched to the creative on that platform. Loop over ads with a for-each whose item is named ad to pause them, scale their budgets, or change their bids. It fires once per change, never again while the label stays put.

Checking triggers: how often, and when

metric and budget.pacing look on a schedule of their own, set with these settings:

SettingEffect
everyMinutesMinutes between checks: 15, 30, 60, 120, 180, 360, 720, 1440, or 10080. Defaults to 60
startHourHour the checking window opens, 0 to 23. Needs endHour too
endHourHour it closes, exclusive. An end before the start covers the night, so 22 to 6 is the late shift
weekdaysDays checking may run, 0 (Sunday) to 6. Absent or empty means every day
timezoneIANA name the hours and days are read in. Defaults to UTC

Outside the window a check is not due at all, however long it has been since the last one. That also gives you a check at a fixed time: an interval of 1440 with a window of 9 to 10 looks once a day, in the nine o'clock hour.

The older frequency setting (hourly, daily, weekly) still works and means 60, 1440, and 10080 minutes.

Measuring over the last few hours

metric reports over the range in range. Alongside the calendar presets (today, yesterday, last_3d, last_7d, last_14d, last_30d) it takes a rolling window written as last_<n>h, up to 48 hours: last_3h at four in the afternoon means one o'clock onwards.

This is the difference between "spent 40 today" and "spent 40 in the last three hours". A day-to-date number only ever climbs, so a rule written against it keeps firing for the rest of the day once it crosses; a rolling window falls again when delivery slows. Both platforms report hours in the ad account's own time zone, and the hour in progress counts.

Thresholds as arithmetic

A condition's value is a number, or a sum written over the matched object's own figures:

spend > 0.06 * dailyBudget
roas < 0.5 * 2.5
cpc > bidAmount

The names available are every metric plus dailyBudget, lifetimeBudget, and bidAmount, each also reachable as ad.dailyBudget and so on. + - * / % and parentheses are supported, and nothing else: these are sums, not code. An object that carries no budget has nothing for 0.06 * dailyBudget to mean, so the condition does not match rather than comparing against zero.

The same arithmetic works on the amounts a step writes. set_ad_budget with an amount of 1.2 * target.dailyBudget raises the budget by a fifth of whatever it is at the time, and target there is the object being written to, which matters when the step climbs to the campaign.

An ad metrics trigger needs at least one condition. Without one it would match every object in the account, which is how "scale the budget 20%" quietly becomes "scale fifty ad sets 20%, every hour", so the save is refused instead.

Trigger filters

Every event trigger accepts optional filters in its settings. A filter that is not set narrows nothing; a filter that asks about a field the event does not carry never matches, so "only Meta" cannot leak onto a platform-agnostic event.

FilterEffect
statusOnly this review status (on review.status_changed) or performance label (on creative.status_changed)
folderIdOnly creatives in this folder
platformOnly meta or only tiktok
adAccountIdOnly this ad account. Meta ids match with or without the act_ prefix, so both act_1234 and 1234 work
tagsOnly creatives carrying one of these tags, as a list or as a, b, c
authorIdOnly actions by this member
excludeSelfSkip events caused by the person who created the automation

Steps

Condition

A condition evaluates a group of rules against the run's variables and branches on the true and false ports. Leaving the false port unconnected stops the run there.

Rules combine under a match mode: all requires every rule, any requires one. A group may hold nested groups (up to three levels deep, twenty rules in total across the tree), and negate inverts a whole group, so "not (spend is high and purchases is zero)" needs no extra step.

Operators:

OperatorMeaning
=, !=Text comparison, case-insensitive
>, <, >=, <=Numeric comparison. A value that is not a number never passes
contains, not_containsSubstring on text. On a list field it is membership: creative.tags contains urgent is true only for the exact tag
starts_with, ends_withPrefix and suffix, case-insensitive
in, not_inMembership in a comma-separated list: creative.status in approved, in_review
matchesCase-insensitive regular expression, up to 200 characters
is_setThe field has a value that is not empty

A rule value may itself use a {{variable}}, so creative.status = {{payload.wanted}} compares two fields of the same run.

Action

An action calls one capability with the arguments you configure. Actions have two ports: out on success and error on failure.

Connecting the error port means you are handling the failure yourself: the message lands on {{error.message}}, the error branch runs, and the run is not marked failed. Leaving error unconnected keeps the strict behaviour, where a failed action fails the run.

An action may also carry a retry policy of up to 4 attempts with up to 60 seconds of backoff. Retries only apply to failures that another attempt could fix, such as a timeout, a dropped connection, or a rate limit from a platform. A rejected argument, a missing permission, or a quota that is already spent fails once and goes straight to the error port.

Available action tools:

add_comment, set_review_status, move_creatives, rename_creative, duplicate_creatives, trash_creatives, create_review_link, publish_creatives, create_smart_ad, generate_copy, send_notification, post_to_channel, send_email, call_webhook, start_creative_test, set_ad_status, update_ad_object, scale_budget, set_ad_budget, scale_bid, set_bid, export_report, run_automation, get_creative_performance.

An action's return value is readable by later steps as {{steps.<stepId>.output}}. get_creative_performance returns the winner, loser, and fatigue labels for every creative, so a scheduled flow can read {{steps.perf.output.performance}} and act on it.

Some of these are governed:

  • scale_budget, set_ad_budget, scale_bid, and set_bid need the launch permission on the person who created the automation. Each takes target: campaign or target: adset to act on the matched object's campaign or ad set instead of the object itself, and the two scaling steps take optional max and min bounds; a step already at its bound reports skipped instead of writing.
  • send_email and export_report with an address need that person to be an owner or admin, and mail can only go to workspace members. A run sends at most five emails.
  • call_webhook needs the API permission and a configured, subscribed webhook endpoint.

AI

An AI step runs a prompt against the run's variables. In decision mode it branches on true and false (plus error); in generate mode it writes text you can read later as {{steps.<stepId>.output}} and continues on out. AI steps are subscriber-only and spend one token per run. A dry run never calls the model.

Wait

A wait pauses that branch, while other branches keep going. The run is stored and picked up by a background sweep, so waits survive deploys.

  • Duration mode waits a relative amount: 1 minute to 30 days.
  • Until mode waits for the next matching clock time: hour, optional minute, optional weekday or dayOfMonth (not both), an optional timezone, and an optional businessDaysOnly that rolls a weekend landing forward to Monday. The target is resolved in its own time zone, so 9am stays 9am across a daylight-saving change.

Wait for an event

An await_event step parks the branch until a matching workspace event arrives. It takes an event name, a timeout of 1 minute to 30 days, and an optional match of a field to a value, usually templated: waiting for review.status_changed where creative.id equals {{creative.id}} waits for this creative, not any creative.

The event arriving continues the out port. The timeout passing continues the error port, which is where you put the "nobody reviewed this in two days" reminder.

For each

A for-each step iterates the array at a path and runs its each branch once per item. itemVar names the item inside the loop (item by default), and each iteration also exposes index and total. maxItems caps the loop, up to 50 items.

The out port runs once, after the loop, so "process every match, then post one summary" is a single flow.

Loop bodies are closed by design: nothing outside the loop may point into the body, the body may not overlap the after-loop branch, and a body may not contain a wait, a wait-for-event, or another for-each. One item failing stops that item only; the loop fails the run only when every item failed.

A metric trigger set to batch mode passes all its matches in matches, which is what a for-each usually iterates.

Join

A join is the only step that may take more than one incoming edge. In all mode it continues once every inbound branch has arrived, and in any mode it continues on the first arrival and ignores the rest. Arrivals are counted across waits, so branches of very different lengths still merge correctly.

Anywhere else, two branches pointing at one step is refused when you save, because the second branch would silently do nothing.

Set values

A values step computes named values from templates and hands them to everything downstream as {{vars.<name>}}. It is where the arithmetic and the string building live, so an action's fields stay readable:

yaml
- id: math
  set:
    cpa: '{{ad.spend | div:{{ad.purchases}} }}'
    headline: '{{creative.name | truncate:40}} needs a look'
  next: notify

Assignments resolve in order, so a later value may build on an earlier one. Up to 10 per step. Inside a for-each loop each iteration gets its own copy, starting from the values computed before the loop, so a value set per item describes that item only.

Send a request

A request step calls an outside service over https and hands its reply to the rest of the flow as {{steps.<stepId>.body}} and {{steps.<stepId>.status}}. A JSON reply is parsed, so {{steps.lookup.body.customer.tier}} works; anything else arrives as text.

  • Method, URL, headers, and body may all use variables, so a token can come from the trigger payload.
  • Any reply outside 2xx takes the error port, with the status and the first part of the body on {{error.message}}. A retry policy makes sense here: timeouts, 429s, and 5xx are retried, while a 401 or a 404 is not.
  • The step needs the API permission on whoever created the automation, and a subscription on the workspace, the same rule the signed webhook endpoint follows.

The guard rails: https only, public internet addresses only (loopback, link-local, and private ranges are refused, in every spelling the URL parser accepts, including IPv4-mapped IPv6), a 10 second timeout covering the reply as well as the connection, no redirects followed, request bodies up to 4,000 characters, replies read up to 8,000 characters and then cut off at the socket, and 20 requests per run counted across waits.

Sub-flows

The run_automation action runs another automation in the same workspace, inline. Its input is exposed to the child as {{input.*}}, and the child can read {{parent.automationName}}. Turn the wait toggle off to start the child and continue without waiting for it.

Sub-flows nest at most two deep, an automation cannot run itself, and the child is subject to its own hourly run limit, so a pair of flows calling each other cannot run away.

Variables and template pipes

Any string argument may reference the run's variables with {{dot.path}}. A string that is exactly one variable keeps the real value (an id, a number, a list); a variable inside a longer string is rendered as text. A path that does not exist renders as empty rather than the word "undefined".

Variables may be formatted with pipes, up to four chained per variable:

{{ad.spend | currency:USD}}          -> $1,234.57
{{ad.spend | round:2}}               -> 1234.57
{{ctr | percent:1}}                  -> 2.4%
{{creative.name | truncate:40}}      -> Summer hook, launch version...
{{creative.name | title}}            -> Summer Hook
{{firstName | default:there}}        -> there
{{matches | length}}                 -> 12
{{tags | join:, }}                   -> urgent, paid
{{createdAt | date:datetime,Europe/Helsinki}}

The full set: default, upper, lower, trim, title, round, floor, ceil, abs, add, sub, mul, div, percent, currency, number, date, truncate, json, length, join, first, last. Pipes take literal arguments, not other variables. An unknown pipe is ignored rather than failing the step.

The webhook trigger

A flow with the webhook trigger gets a secret hook URL. Anything that can POST JSON can start it:

bash
curl -X POST https://upload.ad/api/automations/hooks/<token> \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-8841-retry-safe" \
  -d '{"creativeId": "cr_123", "campaign": "spring"}'

The body is exposed to the flow as {{payload.*}}, so the example above reads {{payload.campaign}}. The URL is the credential: no API key is needed, and anyone holding it can start the flow, so treat it like a password and rotate it if it leaks.

The response returns 202 Accepted immediately and never waits for the run.

Idempotency

Senders retry on timeouts, and an at-least-once delivery must not become two runs. Send an Idempotency-Key header, and the first delivery carrying that key starts a run while any repeat of the same key within 24 hours is answered with the first delivery's result and starts nothing:

json
{
	"ok": true,
	"duplicate": true,
	"status": "ok",
	"error": null,
	"receivedAt": "2026-03-10T09:00:00.000Z"
}

Keys are opaque to us, scoped per automation, and may be up to 200 printable characters. If your sender cannot set headers, put the same value in the body as _idempotencyKey instead; it is stripped before the payload reaches the flow. Sending no key at all keeps the plain at-least-once behaviour.

Two more guards apply to the hook URL: 30 requests per minute per automation, and a 32KB body limit.

Running a flow by hand

A manual flow is never started by the scheduler or by an event. Run it from its page against a selection of creatives or ads, or through the API:

bash
curl -X POST https://upload.ad/api/automations/<id>/run \
  -H "Authorization: Bearer $UPLOADAD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"creativeIds": ["cr_123", "cr_124"]}'

The selection reaches the flow as creatives, ads, selection.creativeIds, selection.adIds, and selection.count, so a for-each over creatives is the usual first step. Up to 50 items per run. Actions execute for real; use the test button, or test_automation, for a dry run.

Agents can do the same through the MCP tool run_automation_manually.

Writing flows as code

Every flow has a YAML form, shown in the builder's code view and returned by get_automation. It maps one to one onto the graph:

yaml
name: Chase stale reviews
trigger: review.status_changed
when:
  status: changes_requested
steps:
  - id: hold
    await: review.status_changed
    match:
      path: creative.id
      value: '{{creative.id}}'
    timeout: 2 days
    next: done
    on_error: chase
  - id: chase
    do: send_notification
    with:
      message: '{{creative.name}} has been waiting two days for changes'
    retry:
      attempts: 3
      backoff: 15
  - id: done
    do: post_to_channel
    with:
      message: '{{creative.name}} came back from review'

next is the out port, yes and no are the branches of a condition or an AI decision, each is a loop body, and on_error is the error port.

Every step kind has a short form: if, do, ai, wait, for_each, join, await, set, and request.

yaml
- id: enrich
  request: GET https://crm.example.com/customers/{{lead.fields.email}}
  headers:
    Authorization: Bearer {{vars.token}}
  retry: 3
  next: label
  on_error: tell_me
- id: label
  set:
    tier: '{{steps.enrich.body.tier | default:unknown}}'
  next: comment

The optional top-level limit and on_failure blocks carry the settings described below.

Limits

LimitValue
Steps per flow120
Steps executed per run600
Runs per automation per hour60
Items per for-each50
Retry attempts per step4, up to 60s backoff
Condition nesting3 groups deep, 20 rules
Pipes per variable4
Sub-flow nesting2 deep
Wait duration1 minute to 30 days
Wait for an event1 minute to 30 days
Emails per run5
Requests per run20, 10s each
Request body / reply read4,000 / 8,000 chars
Values per set step10
Run history kept per automation200 runs

How often a flow may run

An automation that reacts to events reacts to every one of them, which is rarely what you want from a rule that spends money or sends mail. Each automation carries its own rate control, set next to the trigger:

  • Wait between runs holds the flow for a cooldown of 1 minute to 30 days.
  • Only ever run once per thing runs the flow once for each different value of the scope and never again for the same one.
  • Most runs an hour is a plain ceiling, up to the built-in 60.

The scope is a template, which is what makes the limit useful: {{ad.id}} gives every ad set its own cooldown, {{creative.id}} makes a welcome message once-per-creative, and leaving it empty limits the automation as a whole. A scope that resolves to nothing is treated as unscoped rather than collapsing unrelated work onto one bucket.

The claim is taken before the flow runs, so two events arriving in the same instant cannot both get through, and it applies however the flow was reached, including when another automation calls it as a sub-flow. Held-back runs are counted, and the first one in each window is recorded in the run history so the reason is visible without a flood of rows. A held-back run is not a failure: it does not mark the automation as failing and does not use up a slot in the per-hour count. Pressing Run now, replaying a run, and testing all ignore the limit: it is there to bound automatic firing, not to argue with a person.

In code:

yaml
limit:
  mode: cooldown
  every: 1 day
  per: '{{ad.id}}'
  max_per_hour: 10

When a flow keeps failing

A broken automation is quiet by nature, so each one carries its own failure handling:

  • Tell me on the first failure, after a number of failures in a row, or never.
  • Email me as well as the inbox sends the alert to the workspace owner's address too.
  • Turn it off after disables the automation once it has failed that many times in a row, and stops its waiting runs.

The alert fires on the exact count, so a flow that is failing every hour tells you once rather than every hour. A successful run resets the streak. The default, if you set nothing, is one inbox alert after three failures in a row and never disabling.

yaml
on_failure:
  tell: streak
  after: 3
  email: true
  disable_after: 10

Run history

Every run is recorded with its trigger payload and a per-node result: which branch a condition took, how many attempts a retrying action made, how many iterations a loop ran, and the error message of anything that failed. Read it in the dashboard, or with list_automation_runs. A past run can be replayed against its stored payload with replay_automation_run, which executes actions for real.

Editing a flow snapshots the previous version, so list_automation_versions and restore_automation_version can walk it back. Changing or disabling a flow stops its waiting runs rather than resuming them against a graph they were not written for.