Skip to Content
ChannelsGoogleadsGoogle Ads — Search Term Analyst [Live Apr 2026]

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

ComponentLocationStatus
SearchTermReport modelpackages/db/prisma/schema.prismaLive
SearchTermClassification modelpackages/db/prisma/schema.prismaLive
NegativeKeyword modelpackages/db/prisma/schema.prismaLive
CampaignKeyword modelpackages/db/prisma/schema.prismaLive
CampaignExternalMapping modelpackages/db/prisma/schema.prismaLive
search-term-classifier workerpackages/agents/src/workers/search-term-classifier.worker.tsLive
enqueueSearchTermClassifier()packages/queue/src/queues.tsLive
GoogleAdsService.getSearchTerms()packages/providers/google/src/google-ads.tsLive
GoogleAdsService.addNegativeKeyword()packages/providers/google/src/google-ads.tsLive
GoogleAdsService.removeNegativeKeyword()packages/providers/google/src/google-ads.tsLive

What Was Built (Apr 2026)

1. Queue Package — search-term-sync type

packages/queue/src/types.ts

  • Add "search-term-sync" to AgentRole union.
  • Add SearchTermSyncJobData interface:
    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 SearchTermSyncJobData and enqueueSearchTermSync.

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 upserted

Error 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 startSearchTermSyncWorkers and start it alongside other workers.
  • After workers start, register one BullMQ repeatable job on the agent__search-term-sync queue:
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 }

  • null clears 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 SearchTermClassification for that date+channel where status != "pushed" and status != "skipped".
  • For each, uses effectiveDecision:
    • add_as_keywordGoogleAdsService.addKeyword(adGroupId, searchTerm, "BROAD") (new method to be added to GoogleAdsService)
    • add_as_negativeGoogleAdsService.addNegativeKeyword(campaignId, null, searchTerm, "BROAD") + upsert NegativeKeyword row with source: "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 green Pushed badge 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 hits POST /dm/v1/search-terms/trigger to manually fire the sync for the current tenant.

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

ValueMeaningAction on push
add_as_keywordHigh-intent, relevant query — should become a targeting keywordaddKeyword() to ad group
add_as_negativeIrrelevant or money-wasting traffic — block itaddNegativeKeyword() to campaign
watchLow volume, unclear intent — monitor for nowNo action
irrelevantCompletely off-topicNo 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 — mock GoogleAdsService, verify upsert calls and classifier enqueue per campaign.
  • search-term-classifier.worker.test.ts — already exists; verify extractDecisions() 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

  1. Match type on push — should positive keywords always be added as BROAD, or should the DM choose? Initial implementation uses BROAD for simplicity; a match type selector can be added in a follow-up.
  2. Ad group selection for positive keywordsSearchTermReport stores the adGroupId the term appeared in; that is the natural target. If adGroupId is null (campaign-level report), we skip adding as keyword and log a warning.
  3. 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.
  4. Token refresh — the sync worker must check expireOn and refresh if needed before calling getSearchTerms(). The refresh helper already exists in GoogleAdsService but is not wired in the analytics endpoint yet — the sync worker should be the first consumer to wire it.

© 2026 Leadmetrics — Internal use only