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:
- Enable Google Ads API.
- Generate an OAuth 2.0 client (type «Web application» – the redirect URL will be handled by OpenClaw).
- Note
CLIENT_ID,CLIENT_SECRET. - Apply for Basic Access in the Google Ads API centre. Approval still takes ~24 h.
- Fetch a
developerTokenfrom 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:
- CPA threshold. If cost per conversion > $40 and conversions < 3, lower bids 15 %.
- Impression drop. If impressions fall > 30 % WoW, increase budget 10 % up to a hard ceiling.
- 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:
- 6:05 AM: bot posts summary in
#ads-reports. - I skim on phone while coffee happens.
- If I like the proposed cuts: reply «approve».
- If I want deeper detail: reply «details 3» and the bot dumps the full table for campaign ID 3.
- 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:
- Log in → «Agents» → «New».
- Paste code into the inline editor.
- Add secrets in the side drawer.
- 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.