Ewout
By Ewout
October 12, 202613 min read

Google Ads as code: from clicking to reviewing a diff

Company As CodeDevOpsAI
Generated laptop presentation showing a rendered preview of our Dutch Google Search ad for custom software

Google Search is still an important source of leads for a software company like ours. Someone searching for 'custom software development' often has a concrete problem to solve. We want them to find us. Even as we spend more of our own working day talking to an Glossary · In briefLLMLLM stands for large language model: an AI model trained on large amounts of text. It can process language and generate text, including answers, summaries and code.Read more⁠.

But ask a room of developers who would like to spend an afternoon clicking through the Google Ads console, and it goes noticeably quiet. It certainly does here. Campaigns, ad groups, keywords, exclusions, bidding strategies. We know how to build software. We know considerably less about advertising.

Our usual response to this sort of situation is fairly predictable: can we do it as code?

Our Google Ads campaigns now live in Glossary · In brief42KB42KB is the central repository of 10KB for code, company knowledge, documentation and internal tools. It is where we put our company as code approach into practice.Read more⁠, the repository where we manage a growing part of our company. We manage them with readable files, validation, a plan before anything changes and a state file on S3. This fits Glossary · In briefcompany as codeCompany as code is an approach to recording company knowledge, processes and settings in text files and code. This lets you manage them with version control, checks and automation.Read more⁠, our idea of organising the work around our business with the same tools we use for software development.

First the website, then the ads

We write our entire website in Glossary · In briefMDXMDX is a file format that lets you combine Markdown with JSX, JavaScript expressions, and import and export statements.Read more⁠, which combines Glossary · In briefMarkdownMarkdown is a way to add structure to plain text using simple characters, for example for headings, lists, emphasis, and links.Read more⁠ with Glossary · In briefcomponentA component is a distinct part of software with its own task and a clear way of interacting with other parts.Read more⁠. Copy, metadata and layout instructions live in files. Qwik and our plugins turn them into pages. We can search, compare and review changes in the same editor we use to develop software. We explain how that works in the case study about our own website.

We wanted the same for our ads. We find the Google Ads interface hard to navigate. You click from a campaign to an ad group, then look for a setting somewhere else. You cannot simply Glossary · In briefgrepTo grep means to search text for a word or pattern, usually across several files or in a program's output. The term comes from the Unix program grep.Read more⁠ across all your ads to find a phrase. Nor can you do a global find and replace as you would in an editor. Text files let us do both and then review the changes side by side.

Keeping the files in Glossary · In briefversion controlVersion control records changes to code, including who changed what and when. It lets developers collaborate, compare changes and return to an earlier version.Read more⁠ also gives us an automatic Glossary · In briefaudit trailAn audit trail is a chronological record of actions and changes in a system. It shows who or what performed an action, when it happened and what changed.Read more⁠: who changed what, and when? If a change does not work out, a Glossary · In briefrollbackA rollback reverses a change so software, configuration or data returns to an earlier state. Teams use it when a change causes errors or other unwanted effects.Read more⁠ is straightforward: restore the earlier configuration and generate a new plan. After approval, the tool reapplies those settings in Google.

So each landing page got a campaign file beside it. The page and ad address the same search intent and service. Keeping them together makes it easier to update them together:

apps/website/
├── ads.yaml
└── pages/landingspagina's/software-op-maat/
    ├── nl.mdx
    ├── en.mdx
    └── campaign.mdx

MDX is a slightly unusual choice for advertising configuration. It did fit the rest of our website, though. Settings go at the top in YAML, with the ad copy underneath as Markdown. We can use the same file structure for pages and ads.

Here, ads.yaml contains shared settings such as the target country and language. nl.mdx and en.mdx are the two versions of the landing page. campaign.mdx contains the campaign and is read only by our Ads tooling. It extracts the settings and copy without executing MDX code.

Our own Terraform for Google Ads

For the website, we can regenerate and upload all the HTML each time. Doing that with ads would cause problems. A campaign in Google has its own ID, settings and history. Creating a new campaign for every change would lose the connection to what is already running. We would also have two campaigns spending money.

We wanted to manage Google Ads in exactly the same way as our infrastructure through Terraform. The logical answer was, of course, to implement our own Terraform for Google Ads.

With Terraform, you describe your desired infrastructure in files. That might include which servers should exist and how they should be configured. This is called working Glossary · In briefdeclarativeDeclarative means describing the desired outcome without specifying every step the software must take to achieve it.Read more⁠. The tool compares that description with what already exists and produces a plan of the required changes. After approval, apply executes the plan. The next run performs the same comparison again.

Our ads follow the same process. Take an example with fictional amounts: we reduce the monthly budget for custom software in ads.yaml from €900 to €600. For simplicity, we use a 30-day month here. The tool finds the existing shared budget in Google and proposes reducing it from €30 to €20 per day. That amount covers all campaigns in the group together. After approval, the tool makes the change. The next comparison has nothing left to do: the file and Google now describe the same budget.

We built a tool in TypeScript to compare desired and existing settings and execute changes. Most requests go through Zernio's Google Ads API. Zernio provides the connection our program uses to manage campaigns, groups and ads. We also use a direct Google Ads client for features including network settings and images.

By 'our own Terraform', we mean that workflow of configuration, plan and apply. Our tool is a standalone program that manages the Google Search campaigns it creates. Existing campaigns created manually stay outside its scope.

A campaign fits in a text file

A Google Ads campaign has settings including a budget and a schedule. Within the campaign are ad groups, which bring keywords and their corresponding ads together. Our file follows the same structure.

Here is an abbreviated version of our custom software campaign. At the top is the Glossary · In brieffrontmatterFrontmatter is a block of metadata at the beginning of a content file, usually read by the tool that processes the file.Read more⁠, the settings block between the two lines of dashes:

yaml
---
id: software-op-maat
name: Software op maat
deploy: true
status: active
budgetGroup: maatwerk-nieuwbouw
schedule: weekdays
adGroups:
  maatwerk:
    keywords:
      - text: software op maat
        matchType: exact
---

The main settings are:

  • deploy: true include this campaign in the comparison.
  • status: active the desired status when we roll out the campaign with activation.
  • budgetGroup use the shared budget and CPC limit from ads.yaml. In our fictional example, the entire group gets €600 per month, or €20 per day, with a limit of €15 per click.
  • schedule: weekdays allow ads to run on weekdays.
  • adGroups group keywords and ads. Here, the maatwerk group contains one keyword.

The ad copy follows below. The heading ad-group: maatwerk connects it to the group above. Within that group, we name the ad technisch-maatwerk. We have kept the original Dutch copy because this campaign targets Dutch searches:

markdown
## ad-group: maatwerk
### ad: technisch-maatwerk
#### headlines
- Software op maat
- Bedrijfskritische software
- Direct contact met developers
#### descriptions
- 10+ jaar maatwerkervaring. Bouw je bedrijfssoftware samen met onze developers.
- Kies de techniek samen met de developers die je software bouwen en onderhouden.

Our parser reads those fixed headings and lists. It checks that the groups exist, that there are enough headlines and descriptions and that they meet the length limits. The destination URL comes from the Dutch landing page in the same directory. The tooling adds Glossary · In briefUTM tagsUTM tags are URL parameters that identify a link's source, channel and campaign. Web analytics software can use them to attribute visits to that campaign.Read more⁠ so that our web analytics can show which campaign brought a visitor to the site.

We supply several headlines and descriptions. With a responsive search ad, Google selects a combination when serving it. One campaign file can produce different versions of an ad.

Generated phone presentation showing a rendered Google Search ad based on the original Dutch campaign copy

For this article, we had our ad rendered on a phone and laptop as an example.

From configuration to a plan

The campaign file describes what we want. To work out what needs to happen, the tool also needs to know which campaign it refers to and how that campaign is currently configured in Google. It therefore uses three sources:

  • desired is the configuration we want, read from our campaign files.
  • state is our record of previously created campaigns, groups and ads. It contains their Google IDs.
  • live contains the current settings the tool has retrieved from Google using those IDs.

These three inputs go into the function that calculates the differences, commonly called a Glossary · In briefdiffA diff is an overview of the differences between two versions of a file or two states. It shows what has been added, removed or changed.Read more⁠. Current shared budgets and bidding strategies are read separately and passed in as budget. The function receives its inputs as named fields in a single object:

typescript
const operations = diff({ desired, state, live, budget });

operations is the list of actions needed to get from the existing setup to the desired one. These might include creating a campaign, updating a budget or pausing an ad. This list forms the plan we review before anything is executed.

Reading Google each time also reveals changes made outside the repository. Suppose the monthly allocation in ads.yaml translates to a shared daily budget of €20, but someone has changed that budget to €40 in the console. The tool proposes bringing it back to €20. The repository is authoritative for the fields we manage. An external difference like this is called Glossary · In briefdriftDrift is a difference caused by a managed environment changing outside its recorded configuration. The actual settings no longer match what the files describe.Read more⁠.

We run the tool through a Glossary · In briefCLICLI stands for command-line interface: a way to control a program using text commands, usually from a terminal.Read more⁠, a program started from the terminal. It checks the files, retrieves the current settings and shows the plan. We review the proposed changes before the tool executes them. Activating campaigns is a deliberate choice; by default, the tool prepares them in a paused state. An empty diff means everything is already as intended.

A plan can become outdated too. While we are reviewing the changes, a colleague might update a budget. Just before apply, the tool therefore reads the configuration, state and live settings again. If anything has changed, execution stops and we need to review a new plan. Our approval stays tied to the changes we saw.

S3 remembers which campaign is which

Our file uses the local ID software-op-maat for the campaign. Google knows that same campaign by a numeric ID. The tool needs to remember this connection so it can find the correct campaign on the next run. We keep that connection in a state file, just as Terraform does.

We store this record as Glossary · In briefJSONJSON stands for JavaScript Object Notation. It is a text format that software uses to exchange and store structured data.Read more⁠ in Amazon S3, AWS's file storage service. Every run then uses the same state, even when we work from a different laptop. Here is an excerpt, using fictional Google IDs:

json
{
  "campaigns": {
    "software-op-maat": {
      "googleId": "12345678901",
      "adGroups": {
        "maatwerk": {
          "googleId": "23456789012"
        }
      }
    }
  }
}

On the next run, the tool knows that changes to software-op-maat belong to campaign 12345678901. The local ID stays the same if we move the file to another directory. It remains the same campaign in Google.

The complete state also contains the ads, managed images, account and a revision number that increases with each write. If we remove a campaign file, the tool proposes pausing the corresponding campaign. It does so after approval. The IDs stay in the state and the campaign history remains available in Google. The same applies to removed groups and ads.

The S3 bucket holds state.json, saved plans and our approvals or rejections. It is private and encrypted. S3 also keeps earlier versions of the files. Plans and decisions are created once so we can later see what was proposed and approved.

Preventing two runs from overwriting each other

Shared state introduces another problem. Two developers could start the CLI at the same time. If both save their own version of state.json, the last write could lose the first one's changes.

When reading the file, we therefore keep its ETag, a value S3 can use to recognise the content we read. We send it back when saving. S3 accepts the write only if the file has stayed the same. Here is that check, abbreviated from our storage code:

typescript
await client.send(new PutObjectCommand({
  Bucket: bucket,
  Key: 'state.json',
  Body: JSON.stringify(nextState),
  ...(stateEtag
    ? { IfMatch: stateEtag }
    : { IfNoneMatch: '*' }),
}));

PutObjectCommand writes the new JSON to S3. IfMatch asks S3 to compare the ETag first. For the very first write, there is no ETag yet. IfNoneMatch: '*' then allows us to create a new file only. These conditional writes make saving fail if another run has got there first.

We also want to prevent two runs from changing Google at the same time. Before apply, the tool creates a lock.json using a conditional write. Whichever run creates it first can proceed. A second run stops while the lock exists. When finished, the first run removes its own lock.

A stalled process can sometimes leave that lock behind. It deliberately has no automatic expiry, because a slow process might still be working. During recovery, we first check that the earlier run has stopped. Changes through the Google Ads console remain possible. That is why we also check the live settings before and after apply.

New campaigns are created in a paused state. The tool populates the settings and ads, then retrieves them from Google again. Activation follows only if we have chosen it and those checks pass. After apply, we compare everything with the desired configuration one more time. An empty diff means the managed settings match and execution succeeded.

What if Google finishes but the response gets lost?

Suppose we ask Google to create a campaign. Google does so, but the connection drops before the response reaches us. Our client gets a timeout and never receives the new campaign ID. Trying again could create a second campaign.

To recognise this uncertainty, we keep a small journal in the state. Before each API request, we record which change we are about to make. While the request is outstanding, the state contains a pending field:

typescript
state.pending = {
  journalVersion: 1,
  calls: [],
  credentialFingerprint: api.credentialFingerprint,
  planId: plan.id,
  operation: op,
  idempotencyKey: randomUUID(),
  startedAt: new Date().toISOString(),
};
await store.saveState(state);
await journalMutation(api, state, desired, () => store.saveState(state));
delete state.pending;
await store.saveState(state);

The order matters here. First, we save the operation in S3. Then journalMutation executes the change. Before each API request that changes resources, it saves a hash of the request; once the response arrives, it saves that too, before processing any new Google IDs into the state. Only then do we remove pending and save the state again.

The idempotencyKey gives each operation a unique identifier. We also send it with requests to Zernio to help identify repeated requests. If the API outcome is uncertain or saving fails, the operation remains as pending in S3. The next run offers inspection and recovery before a new plan can be executed. We process saved responses without executing the change again. Without a response, the recovery route must establish whether retrying is safe or whether the created resources need to be read back from Google first.

S3 and Google process changes separately. Part of a plan may therefore have succeeded when a later step fails. The journal lets us identify which operation failed and recover based on what was actually executed.

What the LLM era changes

LLMs read text and produce text. That makes this way of working well suited to AI. Our company knowledge, keyword research, landing pages and campaigns all live in text files. An Glossary · In briefagentAn AI agent is software that uses a language model and tools to carry out a task. It chooses its next steps based on the goal and the results of earlier steps.Read more⁠ can read them as context and return its work as changes to those same files. The tooling then turns those changes into a website or a campaign in Google Ads.

That lets an LLM do the work of a marketer. We explain which services we want to sell, who we want to reach and how much we want to spend. The agent can research keywords, organise campaigns, write landing pages and create matching ads. Reading the page and campaign together lets it align the ad's promise with what a visitor finds after clicking.

The results come back as text too. pnpm ads report produces Markdown, JSON and a CSV of search terms. The agent can read these to analyse spending and results, identify irrelevant search terms and propose changes. It then updates the campaign files and requests a new plan. We can use the same workflow to set up campaigns and maintain them.

Making both the inputs and results available as text lets an LLM do this work from start to finish: read the context, update files and propose improvements based on reports. We set the direction and review the changes. The tooling turns the approved files into an actual website or campaign.

Key takeaways

  • Our Google Ads live in text files alongside the landing pages. We can compare and review changes, then roll them out with plan/apply.

  • An LLM handles our keyword research, campaign setup, landing pages and ad copy. We set the goals and budget and approve changes.

  • For our small-scale campaigns, we can now easily manage Google Ads ourselves without bringing in an online marketer.

Ewout

Contact person: Ewout

Work together?

Have a software question or a project in mind? Get in touch with Ewout to discuss what you need and how we can help.

CONTACT

Get in touch with us

Have a question or want to discuss your software? Leave your details and we will get back to you soon.