Google Ads — Search Term Analyst [Live Apr 2026]
Daily agent that pulls every search query that triggered an ad for the day, classifies each one as positive, negative, or watch using Claude, and presents the results to the DM user for review. The DM can override any decision, then push the changes directly back to the Google Ads campaigns.
User Flow
02:00 UTC daily
└─ search-term-sync worker fires
├─ for each tenant with active Google Ads channel
│ └─ for each mapped Campaign
│ ├─ GoogleAdsService.getSearchTerms(yesterday)
│ ├─ upsert SearchTermReport rows
│ └─ enqueue search-term-classifier job (batch per campaign)
│
└─ search-term-classifier worker fires (existing)
├─ calls Claude → add_as_keyword | add_as_negative | watch | irrelevant
└─ upserts SearchTermClassification rows (status: "pending")
DM Portal — /search-terms
├─ DM picks date + channel
├─ Sees three sections: Positive / Negative / Watch
├─ Can override any AI decision via dropdown
├─ Clicks "Push to Ads"
│ ├─ add_as_keyword → Google Ads API addKeyword (to ad group)
│ ├─ add_as_negative → GoogleAdsService.addNegativeKeyword (campaign-level)
│ └─ watch / irrelevant → no action, status = "skipped"
└─ SearchTermClassification.status → "pushed", pushedAt = now()What Already Exists
| Component | Location | Status |
|---|---|---|
SearchTermReport model | packages/db/prisma/schema.prisma | Live |
SearchTermClassification model | packages/db/prisma/schema.prisma | Live |
NegativeKeyword model | packages/db/prisma/schema.prisma | Live |
CampaignKeyword model | packages/db/prisma/schema.prisma | Live |
CampaignExternalMapping model | packages/db/prisma/schema.prisma | Live |
search-term-classifier worker | packages/agents/src/workers/search-term-classifier.worker.ts | Live |
enqueueSearchTermClassifier() | packages/queue/src/queues.ts | Live |
GoogleAdsService.getSearchTerms() | packages/providers/google/src/google-ads.ts | Live |
GoogleAdsService.addNegativeKeyword() | packages/providers/google/src/google-ads.ts | Live |
GoogleAdsService.removeNegativeKeyword() | packages/providers/google/src/google-ads.ts | Live |
What Was Built (Apr 2026)
1. Queue Package — search-term-sync type
packages/queue/src/types.ts
- Add
"search-term-sync"toAgentRoleunion. - Add
SearchTermSyncJobDatainterface:interface SearchTermSyncJobData { agentRole: "search-term-sync"; wakeReason: "scheduled"; date: string; // "YYYY-MM-DD" — the day to fetch search terms for }
packages/queue/src/queues.ts
- Add
enqueueSearchTermSync(date: string): Promise<string>— timestamp-based jobId so daily re-runs never collide with each other.
packages/queue/src/index.ts
- Export
SearchTermSyncJobDataandenqueueSearchTermSync.
2. search-term-sync.worker.ts
File: packages/agents/src/workers/search-term-sync.worker.ts
Queue: agent__search-term-sync
Concurrency: 1 (global, one run at a time)
processJob(job: { date: "YYYY-MM-DD" })
1. Find all ConnectedChannels where:
type = "google_ads"
isConnected = true
2. For each channel:
a. Decrypt tokenInfo → { accessToken, refreshToken }
(refresh if expireOn < now + 5min using GoogleAdsService.refreshToken())
b. Read customerId from subChannelInfo.id
c. Find all CampaignExternalMappings for this channel
(platform = "google_ads", gives us campaignId + externalCampaignId)
d. For each mapping:
i. GoogleAdsService.getSearchTerms(date, date, externalCampaignId)
Returns: [{ searchTerm, adGroupId, matchType, impressions, clicks,
cost, conversions, ctr, avgCpc }]
ii. For each term → db.searchTermReport.upsert()
unique key: [campaignId, searchTerm, syncDate]
iii. Collect created/updated IDs → enqueueSearchTermClassifier({
tenantId, campaignId, searchTermIds })
3. Log summary: tenants processed, channels processed, terms upsertedError handling: per-channel errors are caught and logged; they do not abort other channels. A failure counter is written to the job result.
3. Daily Cron — Agents Server
File: apps/servers/agents/src/index.ts
- Import
startSearchTermSyncWorkersand start it alongside other workers. - After workers start, register one BullMQ repeatable job on the
agent__search-term-syncqueue:
const syncQueue = getQueue("search-term-sync");
await syncQueue.add(
"daily-sync",
{ agentRole: "search-term-sync", wakeReason: "scheduled", date: "auto" },
{
jobId: "search-term-sync__daily",
repeat: { pattern: "0 2 * * *" }, // 02:00 UTC every day
}
);When date === "auto" the worker resolves it to yesterday (UTC) at runtime so the job definition never needs updating.
4. DM API Router — /dm/v1/search-terms
File: apps/api/src/routers/dm/search-terms.ts
Register in: apps/api/src/app.ts AND apps/api/src/index.ts
All routes require requireDmAuth() middleware and the tenant gate (active tenantId from DM session).
GET /dm/v1/search-terms
List of distinct sync dates for the active tenant that have at least one SearchTermClassification.
Query params: channelId? (filter to one Google Ads channel)
Response:
[
{
"date": "2026-04-28",
"channelId": "clxxx",
"channelTitle": "Google Ads — Acme",
"total": 142,
"pending": 38,
"pushed": 104
}
]GET /dm/v1/search-terms/:date/:channelId
All SearchTermClassification rows for the given date and channel, with the parent SearchTermReport data joined.
Response:
{
"date": "2026-04-28",
"channelId": "clxxx",
"items": [
{
"classificationId": "clyyy",
"searchTerm": "buy running shoes online",
"campaignId": "clzzz",
"campaignName": "Brand — Search",
"adGroupId": "clwww",
"impressions": 48,
"clicks": 12,
"cost": 3.84,
"conversions": 1,
"ctr": 25.0,
"avgCpc": 0.32,
"aiClassification": "add_as_keyword",
"aiRationale": "High CTR, relevant to product",
"dmDecision": null,
"effectiveDecision": "add_as_keyword",
"status": "pending"
}
]
}effectiveDecision = dmDecision ?? aiClassification (computed, not stored).
PATCH /dm/v1/search-terms/classifications/:id
DM overrides the classification for one term.
Body: { "dmDecision": "add_as_keyword" | "add_as_negative" | "watch" | "irrelevant" | null }
nullclears the override (reverts to AI decision).- Sets
status = "dm_reviewed"if dmDecision is non-null, else back to"pending".
POST /dm/v1/search-terms/push
Applies all reviewed (and pending) classifications to Google Ads.
Body: { "date": "YYYY-MM-DD", "channelId": "clxxx" }
- Fetches all
SearchTermClassificationfor that date+channel wherestatus != "pushed"andstatus != "skipped". - For each, uses
effectiveDecision:add_as_keyword→GoogleAdsService.addKeyword(adGroupId, searchTerm, "BROAD")(new method to be added to GoogleAdsService)add_as_negative→GoogleAdsService.addNegativeKeyword(campaignId, null, searchTerm, "BROAD")+ upsertNegativeKeywordrow withsource: "search_term_classification"watch/irrelevant→ no API call;status = "skipped"
- Sets
status = "pushed",pushedAt = now()for actioned rows. - Returns
{ pushed: N, skipped: M, errors: [...] }.
5. DM Portal Page — /search-terms
File: apps/dm/src/app/(dm)/search-terms/page.tsx
Layout
┌─────────────────────────────────────────────────────────────┐
│ Topbar │
├──────────────┬──────────────────────────────────────────────┤
│ Left panel │ Main content │
│ │ │
│ Date picker │ Search Terms — 28 Apr 2026 │
│ (calendar │ Google Ads — Acme Corp │
│ or list of │ │
│ available │ ┌─ Positive (42) ──────────────────────┐ │
│ dates) │ │ buy running shoes online │ │
│ │ │ ★ 48 imp · 12 clicks · $3.84 cost │ │
│ Channel │ │ AI: add as keyword — High CTR │ │
│ selector │ │ [Override ▼] ✓ Add as keyword │ │
│ (if tenant │ └────────────────────────────────────────┘ │
│ has >1 │ │
│ Google Ads │ ┌─ Negative (31) ──────────────────────┐ │
│ channel) │ │ free running shoes │ │
│ │ │ ✕ 120 imp · 0 clicks · $0.00 │ │
│ │ │ AI: add as negative — No intent │ │
│ │ │ [Override ▼] │ │
│ │ └────────────────────────────────────────┘ │
│ │ │
│ │ ┌─ Watch (28) ─────────────────────────┐ │
│ │ │ running shoe review │ │
│ │ │ ~ 8 imp · 1 click · $0.24 │ │
│ │ └────────────────────────────────────────┘ │
│ │ │
│ │ ┌─ Irrelevant (41) ────────────────────┐ │
│ │ │ Collapsed by default │ │
│ │ └────────────────────────────────────────┘ │
│ │ │
│ │ [ Push to Google Ads ] │
└──────────────┴──────────────────────────────────────────────┘Component Structure
SearchTermsPage (server, fetches dates list)
└─ SearchTermsClient (client, manages selected date/channel state)
├─ DateChannelSidebar
├─ TermSection ("Positive" | "Negative" | "Watch" | "Irrelevant")
│ └─ TermRow (per search term)
│ ├─ metrics strip (impressions, clicks, cost)
│ ├─ AI rationale pill
│ └─ Override dropdown (select or clear)
└─ PushButton (disabled until at least one term is pending/dm_reviewed)UX Details
- Sections default state: Positive and Negative are expanded; Watch is collapsed; Irrelevant is collapsed (terms with zero clicks and high impression waste).
- Override dropdown values:
Add as keyword,Add as negative,Watch,Irrelevant,— Reset to AI decision —. - Overridden rows get a subtle highlight to distinguish from AI-only decisions.
- Push button shows a loading spinner. On success it refreshes the list and shows a toast:
"Pushed N changes to Google Ads". - If
status === "pushed"the row shows a greenPushedbadge and the override dropdown is disabled. - Empty state:
"No search terms found for this date. The daily sync runs at 02:00 UTC."with a"Run now"button that hitsPOST /dm/v1/search-terms/triggerto manually fire the sync for the current tenant.
Nav
Add Search Terms entry to the DM portal sidebar under the existing Ads group (or create it if absent). Use the Search icon from lucide-react.
6. Manual Trigger Endpoint (optional, for empty state)
POST /dm/v1/search-terms/trigger
- Fetches yesterday’s search terms for the active tenant immediately (not via BullMQ; runs inline with a 30s timeout).
- Returns 202 if enqueued, 409 if a sync for the same date is already running.
- Useful during onboarding / first connection.
GoogleAdsService — New Method Needed
addKeyword(adGroupId: string, keyword: string, matchType: "BROAD" | "PHRASE" | "EXACT"): Promise<string>
Uses the Google Ads ad_group_criteria mutate endpoint to create a keyword criterion. Returns the external criterion ID. Should upsert a CampaignKeyword record after success.
Classification Enum Reference
| Value | Meaning | Action on push |
|---|---|---|
add_as_keyword | High-intent, relevant query — should become a targeting keyword | addKeyword() to ad group |
add_as_negative | Irrelevant or money-wasting traffic — block it | addNegativeKeyword() to campaign |
watch | Low volume, unclear intent — monitor for now | No action |
irrelevant | Completely off-topic | No action |
AgentConfig Seed Entry
The search-term-classifier role already has an AgentConfig row (system prompt seeded in packages/db/prisma/seed.ts). No changes needed.
Testing
Unit tests
search-term-sync.worker.test.ts— mockGoogleAdsService, verify upsert calls and classifier enqueue per campaign.search-term-classifier.worker.test.ts— already exists; verifyextractDecisions()handles malformed JSON gracefully.- DM router handler tests — mock Prisma + GoogleAdsService, verify push logic applies correct API call per decision type.
E2E (Playwright)
- Connect a Google Ads channel in E2E test tenant (mock OAuth).
- Trigger manual sync via
POST /dm/v1/search-terms/trigger. - Assert classifications appear in the DM review page.
- Override one term → push → verify status badge updates to
Pushed.
Open Questions / Decisions
- Match type on push — should positive keywords always be added as
BROAD, or should the DM choose? Initial implementation usesBROADfor simplicity; a match type selector can be added in a follow-up. - Ad group selection for positive keywords —
SearchTermReportstores theadGroupIdthe term appeared in; that is the natural target. IfadGroupIdis null (campaign-level report), we skip adding as keyword and log a warning. - Historical data — the sync fetches only yesterday’s data by default. A backfill endpoint (fetch last N days) could be added if DMs want to retroactively review older terms.
- Token refresh — the sync worker must check
expireOnand refresh if needed before callinggetSearchTerms(). The refresh helper already exists inGoogleAdsServicebut is not wired in the analytics endpoint yet — the sync worker should be the first consumer to wire it.