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.
| Trigger | Fires when |
|---|---|
review.status_changed | A creative moves to approved, changes requested, or in review |
comment.added | Someone comments on a creative, including guests on a review link |
comment.resolved | A comment thread is resolved |
creative.uploaded | A new creative finishes uploading |
creative.version_uploaded | A new version is added to an existing creative |
share_link.viewed | A review link is opened |
share_link.commented | Someone comments through a review link |
ad.published | A creative is published to Meta or TikTok |
ad.rejected | A platform rejects an ad, with the rejection reason on the payload |
lead.received | A Meta lead ad submission is pulled in |
upload.failed | A publish to a platform fails |
member.joined | Someone accepts a workspace invitation |
test.started | A creative test starts |
test.completed | A creative test finishes |
test.promoted | A test winner is promoted |
test.killed | A test loser is stopped |
creative.status_changed | The hourly performance rollup relabels a creative as a winner, loser, fatigued, or back to neutral |
connection.lost | A Meta or TikTok connection stops working |
storage.threshold | Workspace storage passes 80% or 100% of the storage your plan includes |
Schedules, metrics, and manual runs
| Trigger | Fires when |
|---|---|
schedule.daily | Once a day at the hour and minute you pick, in your time zone |
schedule.weekly | Once a week on the weekday you pick |
schedule.monthly | Once 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 |
metric | A recurring check of ad performance against your conditions, as often as every 15 minutes |
budget.pacing | A budget plan crosses your pace threshold |
webhook | A POST arrives at the flow's secret hook URL |
manual | Never 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:
| Setting | Effect |
|---|---|
everyMinutes | Minutes between checks: 15, 30, 60, 120, 180, 360, 720, 1440, or 10080. Defaults to 60 |
startHour | Hour the checking window opens, 0 to 23. Needs endHour too |
endHour | Hour it closes, exclusive. An end before the start covers the night, so 22 to 6 is the late shift |
weekdays | Days checking may run, 0 (Sunday) to 6. Absent or empty means every day |
timezone | IANA 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 > bidAmountThe 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.
| Filter | Effect |
|---|---|
status | Only this review status (on review.status_changed) or performance label (on creative.status_changed) |
folderId | Only creatives in this folder |
platform | Only meta or only tiktok |
adAccountId | Only this ad account. Meta ids match with or without the act_ prefix, so both act_1234 and 1234 work |
tags | Only creatives carrying one of these tags, as a list or as a, b, c |
authorId | Only actions by this member |
excludeSelf | Skip 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:
| Operator | Meaning |
|---|---|
=, != | Text comparison, case-insensitive |
>, <, >=, <= | Numeric comparison. A value that is not a number never passes |
contains, not_contains | Substring on text. On a list field it is membership: creative.tags contains urgent is true only for the exact tag |
starts_with, ends_with | Prefix and suffix, case-insensitive |
in, not_in | Membership in a comma-separated list: creative.status in approved, in_review |
matches | Case-insensitive regular expression, up to 200 characters |
is_set | The 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, andset_bidneed the launch permission on the person who created the automation. Each takestarget: campaignortarget: adsetto act on the matched object's campaign or ad set instead of the object itself, and the two scaling steps take optionalmaxandminbounds; a step already at its bound reportsskippedinstead of writing.send_emailandexport_reportwith 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_webhookneeds 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, optionalminute, optionalweekdayordayOfMonth(not both), an optionaltimezone, and an optionalbusinessDaysOnlythat 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:
- id: math
set:
cpa: '{{ad.spend | div:{{ad.purchases}} }}'
headline: '{{creative.name | truncate:40}} needs a look'
next: notifyAssignments 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
errorport, 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:
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:
{
"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:
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:
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.
- 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: commentThe optional top-level limit and on_failure blocks carry the settings described below.
Limits
| Limit | Value |
|---|---|
| Steps per flow | 120 |
| Steps executed per run | 600 |
| Runs per automation per hour | 60 |
| Items per for-each | 50 |
| Retry attempts per step | 4, up to 60s backoff |
| Condition nesting | 3 groups deep, 20 rules |
| Pipes per variable | 4 |
| Sub-flow nesting | 2 deep |
| Wait duration | 1 minute to 30 days |
| Wait for an event | 1 minute to 30 days |
| Emails per run | 5 |
| Requests per run | 20, 10s each |
| Request body / reply read | 4,000 / 8,000 chars |
| Values per set step | 10 |
| Run history kept per automation | 200 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:
limit:
mode: cooldown
every: 1 day
per: '{{ad.id}}'
max_per_hour: 10When 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.
on_failure:
tell: streak
after: 3
email: true
disable_after: 10Run 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.