bidsmith plan
plan is the dry run. It parses your .bid files, fetches live
state via the Google Ads API, diffs the two, and shows you what
would change — without changing anything. Safe to run any number of
times.
By default plan lists only the resources that would be created,
updated, or destroyed, and ends with a one-line summary that counts the
unchanged ones, plus a Budget: line that says what the change costs
per day. If there are no changes you’ll see just those two lines — no
per-resource rows. Pass --show-unchanged to list every resource,
including no-op rows.
If plan reports no changes, your .bid files and the live account
already match on the settings bidsmith models. If plan has rows,
apply is the next step.
What unchanged covers
Any plan that counts unchanged resources ends with a line saying what that count is a statement about:
Plan: 0 to create, 0 to update, 0 to destroy, 2 to adopt, 1447 unchanged. (2 accepted, 0 rejected)Note: `unchanged` compares the fields bidsmith models. Run `bidsmith drift` for the live fields it does not.Google Ads has far more settings per campaign than bidsmith models, and
a setting bidsmith doesn’t model isn’t just left alone — it’s never
fetched, so it can’t show up as a difference. unchanged therefore means
“nothing changed among the fields on this page,” not “nothing about this
campaign differs from your repo.”
bidsmith drift reports the rest: which
settings fall outside the comparison, and which of those are actually set
on your account.
Synopsis
bidsmith plan [PATH] [--refresh-state] [--offline] [--show-unchanged] [--format text|markdown] [--detailed-exitcode] [--whoami] [--read-live] [--verbose]Arguments
| Argument | Default | Description |
|---|---|---|
PATH | . | File or directory containing .bid files. |
Flags
| Flag | Description |
|---|---|
--refresh-state | Ignore any cached live state and refetch from the API — a cache hit can never satisfy this flag, however fresh the entry looks. The fresh fetch is then written back to the cache. Use when you suspect the cache is out of date, or when a plan came back red and you want to rule that out. |
--offline | Diff against the cached live state without contacting Google Ads at all — no OAuth, no SearchStream, no validateOnly mutate. Errors if no fresh cache exists. See Cut Google Ads API quota usage. |
--show-unchanged | List every resource, including unchanged no-op rows. By default plan prints only resources that would change. Useful for auditing exactly what bidsmith is tracking. |
--format <text|markdown> | Output format. text (default) is the aligned per-resource listing. markdown renders the diff as a table suited to posting as a pull-request comment — this is what the workflow bidsmith init scaffolds uses. |
--detailed-exitcode | Exit 2 (instead of 0) when the diff is non-empty, keeping 1 for errors — like terraform plan -detailed-exitcode. Lets CI tell “changes are pending” apart from “the plan failed.” |
--whoami | Exchange the refresh token for an access token and print the result. Doesn’t touch Google Ads. Useful for confirming credentials. |
--read-live | Print a summary of the live account state (resource counts per type). Doesn’t require .bid files. Useful for debugging. |
--verbose | Print the outgoing API request envelope and the raw response. Useful when something looks wrong. |
Environment variables
| Variable | Required? | Notes |
|---|---|---|
GOOGLE_ADS_DEVELOPER_TOKEN | yes | |
GOOGLE_ADS_CLIENT_ID | yes | |
GOOGLE_ADS_CLIENT_SECRET | yes | |
GOOGLE_ADS_REFRESH_TOKEN | yes | |
GOOGLE_ADS_CUSTOMER_ID | yes | The account to plan against. |
GOOGLE_ADS_LOGIN_CUSTOMER_ID | no | Set if you’re managing a sub-account via an MCC. |
BIDSMITH_API_VERSION | no | Defaults to v25. Bump if you hit a “retired version” error. |
See Connect to Google Ads for how to obtain these.
Exit codes
| Code | Meaning |
|---|---|
0 | Plan completed. Without --detailed-exitcode, this covers both “no changes” and “changes pending.” With --detailed-exitcode, 0 means no changes. |
1 | Local validation failed, authentication failed, Google rejected the validate-only request, bidsmith refused to send a batch it knows the account can’t accept (see below), or any other error. |
2 | Only with --detailed-exitcode: the plan succeeded and the diff is non-empty (changes are pending). |
Examples
Standard plan
bidsmith plan .Validates .bid files, fetches live state, diffs, prints the plan.
Confirm credentials work
bidsmith plan --whoamiPrints a short summary like:
Refresh token exchanged successfully.Access token expires in 3599 seconds.Customer ID: 1234567890Useful as the first command to run after setting env vars.
See what’s in the live account
bidsmith plan --read-liveSkips the diff and just prints a per-type count. Helpful when you’re
new to an account and want to know what’s already there before
writing .bid files.
Debug a confusing diff
bidsmith plan . --verbosePrints the request body bidsmith sent and the response Google returned. Useful when bidsmith’s interpretation of the diff doesn’t match yours.
Render the diff for a pull-request comment
bidsmith plan . --format markdown --detailed-exitcodePrints a Markdown table — Resource | Action | Result — followed by a
bold summary line. bidsmith init scaffolds a GitHub Actions workflow
that captures this output and posts it as a comment on every pull
request; --detailed-exitcode lets the job exit 2 for “changes
pending” and reserve 1 for a genuine failure.
Reading the rows
Every row says what it writes, and an ~ update row says what each
field becomes:
google_ads_campaign.brand ~ adopt (label only; claims frequency caps, languages, locations)google_ads_campaign.winter ~ update (status: "PAUSED" -> "ENABLED")google_ads_campaign_budget.winter ~ update (amount_micros: 30000000 -> 45000000)
Note: ~ adopt / ~ claim rows write bidsmith's own labels only: every field theydeclare already matches live, so no campaign, ad group, or targeting value iswritten. A claim records which criterion categories and asset kinds this filemanages, so removing the last declared member of one later prunes the livemembers too.| Row | What it writes |
|---|---|
+ create | A new resource. |
~ update (field: was -> becomes, …) | Only the fields listed, to the values on the right of each arrow. |
- destroy | Removes the resource (or the criterion named in the row). |
~ pause | Switches off an extension Google’s asset automation attached. Paused, not removed — bidsmith cannot recreate one, so a deleted one comes straight back. |
~ adopt (label only) | A bidsmith:address label on a live resource that already matches the file, field for field. Nothing else. |
~ adopt (label only; claims …) / ~ claim (label only; claims …) | The same label write, plus a bidsmith:owns=<category> marker. See what the ~ claim rows mean. |
no-op | Nothing — shown only with --show-unchanged. |
The distinction matters most on a campaign that is already spending: a
category named on an adopt or claim row is a marker, not new
targeting. If targeting were really changing, it would appear as its
own + create / - destroy rows.
How the diff is computed
- Resources are matched between
.bidand live by name. - For each matched pair, bidsmith compares scalar attributes
field-by-field; any difference becomes a
~ updaterow with the before/after values. - Resources in
.bidwith no live match become+ createrows. - A keyword removed from a resource that still exists becomes a
- destroyrow: if your file still lists some negatives (or keywords) for an ad group or campaign, that list is the complete set, so a live keyword no longer in it is removed. The pruning stays within what you manage — declaring negatives never destroys positives, and vice versa. - The same rule reaches extensions: once a campaign or ad group
declares one sitelink (or callout, or snippet), a live one of that
kind that your files don’t list becomes a
- destroyrow. Account- level extensions attach to every campaign at once, so those are only removed when theproviderblock’sownslist says to — see Serve only the extensions you declared. - An extension Google attached by itself, inside a scope your files
own, becomes a
~ pauserow rather than a- destroyrow. For the kinds no block can declare — a business name, a logo — the campaign claims them withowns = ["automatically_created_assets"]; until something does, they are counted in a warning on every run. - A whole resource that bidsmith created earlier (it carries a
bidsmith label) and that your files no longer declare becomes a
- destroyrow. A campaign somebody built in the Google Ads UI carries no label, so it is never destroyed — it’s simply not bidsmith’s to manage. - A removal Google would reject is dropped before the batch is sent
and reported as a warning, because one rejected operation fails
every other operation with it. The summary counts them:
2 to destroy (1 skipped).
After producing the diff, bidsmith sends the create/update/remove
operations to Google’s googleAds:mutate endpoint with
validateOnly=true.
Google checks them without committing. Any rejection
(“headline too long,” “bid below minimum”) shows up inline in the
plan output.
What the plan was working from
Before the diff, plan says which account it read, when, and whether the answer came from the API or from your local cache:
plan: live state for customers/1234567890 read just now (fresh read).plan: live state for customers/1234567890 read 4m12s ago (cached — --refresh-state to refetch).If the plan is not clean, the same fact is repeated under the summary, where the rejections are:
Plan: 2 to create, 0 to update, 2 to destroy, 1507 unchanged. (0 accepted, 2 rejected, 4 blocked by those failures)State: diffed against live state for customers/1234567890 read 12m4s ago (cached — --refresh-state to refetch).A rejection is only as true as the snapshot behind it. If the account
changed after that read — a colleague applied, CI merged to main —
plan can reject changes that have in fact already landed. When the
rejections look like that, plan says so and tells you to re-run with
--refresh-state. See A plan reported errors that turned out not to
be real.
What the change costs
Under the operation counts, every plan prints one line about money:
Plan: 0 to create, 0 to update, 0 to destroy, 2 to adopt, 1447 unchanged. (2 accepted, 0 rejected)Budget: committed daily spend 320.00 EUR -> 340.00 EUR (+20.00 EUR/day) across 17 enabled campaignsRead it as: the account commits EUR 320/day today, EUR 340/day once
this change lands, and the difference is EUR 20/day. The figures are in
your account’s currency — no dividing amount_micros by a million in
your head.
Three things the line is careful about:
- Only campaigns that will be enabled after the change count. A budget attached to a paused campaign commits nothing, so it’s left out — and pausing a campaign shows up as a drop in committed spend.
- A shared budget counts once, however many campaigns draw on it.
- The whole account is counted, not just the campaigns your files touch. That’s the number that answers “what are we running?” — and the one that catches three separate pull requests each adding EUR 20/day.
If nothing about the money changes, the line collapses to the total:
Budget: committed daily spend 320.00 EUR/day across 17 enabled campaignsSince the CI workflow bidsmith init scaffolds posts the plan as a
pull-request comment, the number lands in the review without anyone
having to work it out.
When plan won’t send the batch
Google applies the whole batch or none of it. One operation it refuses takes every other operation down with it — including changes to campaigns you never touched. So bidsmith checks for the refusals it can predict before sending, and does one of two things.
It stops. If your files ask to create or change something on a Video campaign, the plan halts and nothing is sent, because the Google Ads API is read-only for that channel:
plan: error: video_de.google_ads_campaign.gh_video_de has drift on status: "PAUSED" -> "ENABLED". Nothing in the batch can be sent while it is there, because the Google Ads API cannot create or update VIDEO campaigns — make the change in the Google Ads UI, then let bidsmith adopt itMake the change in the Google Ads UI, or put the file back the way the account has it. Either way the plan goes green again.
It stops. If a resource is declared adopt-only —
lifecycle { create = false } — and nothing live matched it, there is
nothing to adopt, and planning a create instead would be the opposite
of what the file asked for:
plan: error: video_fr.google_ads_campaign.gh_video_fr is declared adopt-only (lifecycle { create = false }) but no live campaign matched it, so there is nothing to adopt. bidsmith looks for its bidsmith:address label first, then by name "GH_YouTube_FR Instream 11.08.2026". Create it in the Google Ads UI to match, or drop the lifecycle block to let bidsmith create itSee Adopt a campaign you built in the Google Ads UI.
It skips. If a Video campaign or ad group carries a bidsmith label but is gone from your files, bidsmith would normally destroy it — and the API won’t allow that either. Since nothing is lost by leaving it alone, the removal is skipped, everything else proceeds, and the summary says so:
Plan: 16 to create, 21 to update, 4 to destroy (2 skipped), 1432 unchanged.To make the warning stop, either delete the resource in the Google Ads UI or restore its declaration.
It reports. Some drift bidsmith can neither fix nor declare away, because the Google Ads API has no field for it. Assets Google’s own automation attached to campaigns bidsmith manages are the case that matters most — the account-level switch behind them exists only in the Google Ads UI, so every plan counts what it found:
plan: warning: account: Google's asset automation is serving 6 asset(s)nothing declares (4 SITELINK, 2 CALLOUT). The account-level "automaticallycreated assets" switch that makes them is not in the Google Ads API, sobidsmith can only report it — turn it off in the Google Ads UI. What acampaign can declare is its own automation: `asset_automation_settings`.See Stop Google writing your ad copy.
See also
bidsmith apply— what to run when the plan is what you want.bidsmith validate— local checks without the API.- Plan and apply — the conceptual model.
- Drift — interpreting
~ updaterows.