The quickest way I know to stop wasting money in Google Ads is to make the data annoy you every morning. OpenClaw, the Node-based agent framework that already sits in my Slack workspace, turned out to be the missing link. Below is the exact setup I pushed to production last month: Google Ads API v14, OpenClaw gateway 0.46.2, Node 22.3, and a cron-driven agent that ships performance screenshots, budget alerts, and bid suggestions before 7 AM.

Why bother: tight feedback loops beat fancy dashboards

Google’s own UI gives you every chart imaginable, but it lives in a browser tab you will forget. OpenClaw drops the same numbers into the chat where the team already works. The friction reduction is obvious:

  • No extra log-ins. OAuth is handled once in the daemon.
  • Response time goes from "whenever someone looks" to minutes.
  • The agent can propose bid changes in the same thread, making optimisation conversational.

Prerequisites: Google Ads API credentials + OpenClaw tools

1. Enable the Google Ads API

Create or pick a Google Cloud project, then:

  1. Enable Google Ads API.
  2. Generate an OAuth 2.0 client (type «Web application» – the redirect URL will be handled by OpenClaw).
  3. Note CLIENT_ID, CLIENT_SECRET.
  4. Apply for Basic Access in the Google Ads API centre. Approval still takes ~24 h.
  5. Fetch a developerToken from the «API Centre» in your Google Ads manager account.

2. Set up OpenClaw

I’m assuming you have a running gateway and daemon. If not:

npm install -g @openclaw/gateway@0.46.2 @openclaw/daemon@0.46.2 claw gateway init ads-bot claw daemon start

The hosted alternative (ClawCloud) lets you skip local infra. The code below works the same, you just push via the web UI.

3. Wire Composio for 3-legged OAuth

OpenClaw delegates most OAuth flows to Composio. Add the Google Ads connector:

claw tool add composio-googleads

The CLI walks you through pasting CLIENT_ID, CLIENT_SECRET, and developerToken. Composio stores the refresh token in OpenClaw’s encrypted secrets store.

Coding the agent: pull, analyse, push

All logic lives in adsAgent.js. The pattern is fetch → crunch → notify → (optionally) mutate. I keep the Google Ads SDK separate to stay testable.

// adsAgent.js import { Agent } from '@openclaw/sdk'; import { GoogleAdsApi } from 'google-ads-api'; import dayjs from 'dayjs'; const client = new GoogleAdsApi({ client_id: process.env.CLIENT_ID, client_secret: process.env.CLIENT_SECRET, developer_token: process.env.DEVELOPER_TOKEN, }); const customer = client.Customer({ customer_account_id: process.env.CUSTOMER_ID, refresh_token: process.env.REFRESH_TOKEN, }); export default new Agent({ name: 'ads-optimizer', schedule: '0 6 * * *', // 6:00 AM cron memory: true, async run (ctx) { const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD'); const report = await fetchStats(yesterday); const insights = analyse(report); await ctx.notify(formatMessage(insights)); if (insights.shouldAdjust) await applyBidChanges(insights.adjustments); } });

Fetching stats

async function fetchStats(date) { const query = ` SELECT campaign.id, campaign.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date = '${date}'`; return customer.query(query); }

Analysing performance

I stole the basic math from my old Excel sheet: cost per conversion threshold + impression drop alert + soft bid curve.

function analyse(rows) { const insights = { rows }; insights.wasted = rows.filter(r => microsToUSD(r.metrics.cost_micros) > 50 && r.metrics.conversions === 0); insights.heroes = rows.filter(r => r.metrics.conversions > 5 && cpa(r) < 10); insights.shouldAdjust = insights.wasted.length > 0; insights.adjustments = insights.wasted.map(r => ({ id: r.campaign.id, bid_modifier: 0.85 // cut 15% })); return insights; }

Formatting the chat message

function formatMessage(insights) { const lines = []; lines.push(`📊 Google Ads daily report — ${dayjs().subtract(1,'day').format('D MMM')}`); lines.push(`Total campaigns: ${insights.rows.length}`); if (insights.wasted.length) { lines.push(`⚠️ Campaigns burning money without conversions: ${insights.wasted.length}`); insights.wasted.slice(0,3).forEach(c => lines.push(` • ${c.campaign.name}`)); } if (insights.heroes.length) { lines.push(`🏆 Top performers:`); insights.heroes.slice(0,3).forEach(c => lines.push(` • ${c.campaign.name} — CPA $${cpa(c).toFixed(2)}`)); } if (insights.shouldAdjust) lines.push('Proposing 15% bid decrease on ⚠️ campaigns. Reply "approve" to apply.'); return lines.join('\n'); }

Interactive approval flow

OpenClaw listens for replies on the thread ID returned by ctx.notify(). We gate destructive changes behind a human «approve». The daemon memory keeps state until the reply lands.

export const onMessage = async (ctx) => { if (ctx.message.text.trim().toLowerCase() === 'approve' && ctx.memory.latestInsights) { await applyBidChanges(ctx.memory.latestInsights.adjustments); await ctx.reply('✅ Bid modifiers updated.'); } };

Automated performance reports: daily, weekly, ad-hoc

The cron field in the agent definition is Unix-style. For weekly Monday digests:

schedule: '0 7 * * 1' // 7 AM Monday

You can also trigger on demand:

/trigger ads-optimizer run --since=yesterday --channel=#marketing

The gateway CLI supports slash-command registration across Slack, Discord, Telegram, and WhatsApp Business. WhatsApp still requires Meta’s Cloud API; see the gateway docs for the Webhook URL you paste in Meta’s UI.

Bid adjustment suggestions: simple math that works

Ad people love ML, but our small budgets (< $25k/mo) don’t justify AutoML. I target three signals:

  1. CPA threshold. If cost per conversion > $40 and conversions < 3, lower bids 15 %.
  2. Impression drop. If impressions fall > 30 % WoW, increase budget 10 % up to a hard ceiling.
  3. Hero protection. If CPA < $12 and conversions > 10, lift bids 10 % unless already top 80 % impression share.

The rules live in analyse() so non-devs can tweak numbers in one place. The function returns an array of adjustments:

[{ id: 123, bid_modifier: 1.10 }, ...]

Caveat. Google Ads API won’t let you set a bid modifier below 0.1 or above 4. The SDK will throw RequestError.INVALID_OPERAND. Catch it and log.

applyBidChanges()

async function applyBidChanges(changes) { const operations = changes.map(c => ({ update: { resource_name: `customers/${process.env.CUSTOMER_ID}/campaigns/${c.id}`, bidding_strategy: { manual_cpc: {}, }, bid_modifier: c.bid_modifier }, update_mask: { paths: ['bid_modifier'] } })); await customer.campaigns.batchMutate(operations); }

Keyword analysis automation

The next rabbit hole is irrelevant search terms. We generate a weekly «negative keyword candidates» CSV and send it to the group chat.

Querying search terms

const kwQuery = ` SELECT search_term_view.search_term, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM search_term_view WHERE segments.date DURING LAST_7_DAYS ORDER BY metrics.impressions DESC LIMIT 1000`;

Feed the rows into a quick heuristic:

function keywordCandidates(rows) { return rows.filter(r => r.metrics.conversions === 0 && r.metrics.clicks > 20); }

Write to CSV (I use fast-csv). Then:

await ctx.notify({ file: 'negatives.csv', message: `🗒️ ${candidates.length} negative keyword suggestions.` });

Reviewing ad performance from a messaging app

The killer feature is two-way chat. My workflow looks like this:

  1. 6:05 AM: bot posts summary in #ads-reports.
  2. I skim on phone while coffee happens.
  3. If I like the proposed cuts: reply «approve».
  4. If I want deeper detail: reply «details 3» and the bot dumps the full table for campaign ID 3.
  5. Once a week: reply «negatives» and get the CSV.

The routing uses OpenClaw’s new messagePatterns API (0.46.x):

patterns: [ { regex: /^details (\d+)/, fn: detailsHandler }, { regex: /^negatives$/, fn: negativesHandler } ]

Securing tokens & rate limiting

Google slaps you with 429s if you hammer the API. The SDK implements exponential backoff, but I also throttle my cron to one customer query per second via p-limit.

Secrets live in claw secrets (AES-256 at rest). Avoid raw env files. On ClawCloud, the UI exposes a «Secrets» tab—same backing store.

Deployment: local vs ClawCloud

If you self-host, push with:

claw deploy --gateway=http://localhost:8000

On ClawCloud:

  1. Log in → «Agents» → «New».
  2. Paste code into the inline editor.
  3. Add secrets in the side drawer.
  4. Hit «Deploy». Cold start to first report is ~45 sec.

Logs stream in real-time. claw tail ads-optimizer works across both deployments.

Real-world results after 30 days

  • Ad spend down 18 %.
  • Conversions flat (-0.5 %).
  • Time spent inside Google Ads UI: basically zero.
  • Marketing team stopped pinging engineering for CSV exports.

Not huge, but the cost of building was a Saturday afternoon. Worth it.

Next step: make it yours

Clone the gist (openclaw-ads-starter) and replace the rules in analyse(). If you get stuck, the #openclaw-ads thread on Discord has a dozen people sharing queries. And yes, someone already PR’d ROAS-based adjustments—merge that instead of reinventing.