Skip to content

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

Terminal window
bidsmith plan [PATH] [--refresh-state] [--offline] [--show-unchanged] [--format text|markdown] [--detailed-exitcode] [--whoami] [--read-live] [--verbose]

Arguments

ArgumentDefaultDescription
PATH.File or directory containing .bid files.

Flags

FlagDescription
--refresh-stateIgnore 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.
--offlineDiff 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-unchangedList 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-exitcodeExit 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.”
--whoamiExchange the refresh token for an access token and print the result. Doesn’t touch Google Ads. Useful for confirming credentials.
--read-livePrint a summary of the live account state (resource counts per type). Doesn’t require .bid files. Useful for debugging.
--verbosePrint the outgoing API request envelope and the raw response. Useful when something looks wrong.

Environment variables

VariableRequired?Notes
GOOGLE_ADS_DEVELOPER_TOKENyes
GOOGLE_ADS_CLIENT_IDyes
GOOGLE_ADS_CLIENT_SECRETyes
GOOGLE_ADS_REFRESH_TOKENyes
GOOGLE_ADS_CUSTOMER_IDyesThe account to plan against.
GOOGLE_ADS_LOGIN_CUSTOMER_IDnoSet if you’re managing a sub-account via an MCC.
BIDSMITH_API_VERSIONnoDefaults to v25. Bump if you hit a “retired version” error.

See Connect to Google Ads for how to obtain these.

Exit codes

CodeMeaning
0Plan completed. Without --detailed-exitcode, this covers both “no changes” and “changes pending.” With --detailed-exitcode, 0 means no changes.
1Local 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.
2Only with --detailed-exitcode: the plan succeeded and the diff is non-empty (changes are pending).

Examples

Standard plan

Terminal window
bidsmith plan .

Validates .bid files, fetches live state, diffs, prints the plan.

Confirm credentials work

Terminal window
bidsmith plan --whoami

Prints a short summary like:

Refresh token exchanged successfully.
Access token expires in 3599 seconds.
Customer ID: 1234567890

Useful as the first command to run after setting env vars.

See what’s in the live account

Terminal window
bidsmith plan --read-live

Skips 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

Terminal window
bidsmith plan . --verbose

Prints 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

Terminal window
bidsmith plan . --format markdown --detailed-exitcode

Prints 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 they
declare already matches live, so no campaign, ad group, or targeting value is
written. A claim records which criterion categories and asset kinds this file
manages, so removing the last declared member of one later prunes the live
members too.
RowWhat it writes
+ createA new resource.
~ update (field: was -> becomes, …)Only the fields listed, to the values on the right of each arrow.
- destroyRemoves the resource (or the criterion named in the row).
~ pauseSwitches 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-opNothing — 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

  1. Resources are matched between .bid and live by name.
  2. For each matched pair, bidsmith compares scalar attributes field-by-field; any difference becomes a ~ update row with the before/after values.
  3. Resources in .bid with no live match become + create rows.
  4. A keyword removed from a resource that still exists becomes a - destroy row: 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.
  5. 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 - destroy row. Account- level extensions attach to every campaign at once, so those are only removed when the provider block’s owns list says to — see Serve only the extensions you declared.
  6. An extension Google attached by itself, inside a scope your files own, becomes a ~ pause row rather than a - destroy row. For the kinds no block can declare — a business name, a logo — the campaign claims them with owns = ["automatically_created_assets"]; until something does, they are counted in a warning on every run.
  7. A whole resource that bidsmith created earlier (it carries a bidsmith label) and that your files no longer declare becomes a - destroy row. 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.
  8. 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 campaigns

Read 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 campaigns

Since 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 it

Make 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 it

See 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 "automatically
created assets" switch that makes them is not in the Google Ads API, so
bidsmith can only report it — turn it off in the Google Ads UI. What a
campaign can declare is its own automation: `asset_automation_settings`.

See Stop Google writing your ad copy.

See also