Paid search copy agent
This page explains how a paid search copy agent reads campaign structure, keywords, and landing pages in the ad platform — read-only, under an interactive grant — and generates reviewable copy drafts checked against a versioned claims policy. After reading it, you will understand which modules this scenario needs, why bids and budgets must stay outside the delegated scope, and why data like claims rules belongs in a policy store rather than Memory.
Use case
Growth teams need to create ad headlines and descriptions from keyword groups, competitor ads, landing pages, and brand rules. Campaign structure and keywords in the ad platform are required inputs, but the same account also controls bids, budgets, and serving status — handing it to a copy-generating script hands over control of the entire ad spend.
Typical triggers:
- A new batch of keyword groups goes live, and multiple headline and description variants are needed before launch.
- A landing page redesign leaves existing ad copy out of sync with the page, requiring a batch rewrite.
- Competitor ad wording shifts, and copy direction must be updated against public SERPs.
Engineering challenges
- High variant volume, high compliance cost: dozens of keyword groups × multiple headline and description variants, each checked against forbidden claims and against what the landing page can actually support. Manual review always misses some, and an out-of-bounds claim that goes live is a compliance and platform-penalty risk.
- Copy provenance is hard to trace: which keyword group, which page version, and which rule each variant was based on — scattered spreadsheets cannot support pre-launch review or later audits.
- Borrowed ad-account access is too broad: an ad account inherently carries bid, budget, and serving controls, while the copy task only needs to read structure and keywords and produce drafts. One slip directly affects real ad spend.
Module composition
| Module | Role | Notes |
|---|---|---|
| GenAuth | Core | Read-only delegation, revocation, and the audit chain for the ad platform; bid, budget, and serving actions stay outside the grant. |
| Web Agent | Core | Controlled sessions sign in to the ad platform (Profiles can reuse login state) to read campaign structure read-only, extract landing page content, and check public SERPs and competitor ads with WebSearch. |
| GUMem | Not used | Forbidden claims and brand rules are versioned policy — keep them in your policy store and inject them per version; performance history is business state and belongs in your analytics store. Neither is Memory. This scenario has no personal user preference worth persisting across tasks. |
Permission and delegation boundaries
The Agent holds no inherent permissions. The effective authority for each copy task is the intersection of three sets: what the user actually holds ∩ what was explicitly delegated for this task ∩ what the enterprise has approved. Applied here:
- The delegated scope covers only "read the selected account's campaign structure, keyword lists, and landing pages, and create copy drafts" — no changes to bids, budgets, serving status, or targeting.
- Delegation credentials are short-lived; minute-level validity is recommended for a single copy task, with re-delegation after expiry.
- The user or an administrator can revoke the grant at any time; new read or draft requests fail immediately after revocation.
- Out-of-scope attempts (for example, touching a budget or serving switch) are rejected and recorded — the audit chain covers all attempts, not just successful actions.
Note: this sample requests doAnything product permissions. Your application and downstream services must configure and enforce business limits such as domain lists, page ranges, action whitelists. Product scopes and prompt rules do not enforce those detailed limits; this sample does not configure them. See Delegate token and attenuation.
Workflow
The user selects an ad account, keyword groups, and a target landing page.
GenAuth starts an interactive delegation; after the user confirms in Qoni Console, your server-side callback exchanges it for a least-privilege credential.
Your app loads the current version of forbidden claims, brand rules, and compliance limits from the policy store and injects them into the task.
Web Agent signs in to the ad platform and reads the existing campaign structure and keyword lists, read-only.
Checkpoint: This step permits reads only; any interface action toward bids, budgets, or serving status should be rejected and recorded.
Web Agent extracts landing page content and checks public SERPs and competitor ads through WebSearch for reference.
The Agent generates headlines, descriptions, and variants per keyword group, checking every variant against forbidden claims and annotating the rule it was checked against.
The Agent returns copy drafts, variant comparisons, and review notes with an audit id attached; after app-side validation they enter the review queue.
Checkpoint: No variant should contain forbidden claims or promises the landing page cannot support; non-compliant variants should be removed with the reason stated.
Example code
This example uses @qoniai/qoni 0.9.0, published on npm. The download includes the same SDK version, installed with npm ci. The demo drafts search ads for the synthetic keyword group Firefox browser using the product page. The SDK reads real public pages; business inputs in scenarios.ts are labeled public-demo.
This demo does not read or write GUMem; its task uses the supplied page list and explicit business inputs. DoAnything opens the supplied pages and produces the scenario output. Supply your own JSON with --input; use --interactive when the user must consent to delegation. For site sign-in and user responses, see Qoni SDK.
Download the complete runnable examples, or run from the documentation repository:
cd examples/qoni
npm ci
npm run case -- paid-search-copy-agent
# Supply your own inputs
npm run case -- paid-search-copy-agent --input /path/to/input.jsonSet server-side QONI_ACCESS_KEY and QONI_SECRET_KEY. QONI_USER_ID can identify your application's current GenAuth user; the local demo otherwise selects a user from the bound pool. Demonstration Memory writes use an isolated user rather than changing a business user's preferences.
This scenario's executable entry point:
import { cliOptions } from '../runtime.js'
import { inputFile, runScenario } from '../run-case.js'
// Draft ads using product evidence and the prompt-defined length limits.
const report = await runScenario('paid-search-copy-agent', cliOptions(), inputFile())
// report.items: keyword groups, headlines, descriptions, and rule IDs for ad-review staff.
// Validated fields: keywordGroup, headline, description, ruleId.
console.log(JSON.stringify(report, null, 2))The entry point loads the definition below by scenario ID. The code is included directly from scenarios.ts, with comments shown in the page language: the task, output fields, source field, Web Search queries (if any), whether Memory is used, and the demonstration input. The pipeline appends the input data, search sources, recalled Memory, and shared safety constraints to the task to build the final prompt; see the pipeline below for the full assembly.
// Draft ads using product evidence and the prompt-defined length limits.
browser('paid-search-copy-agent',
'Draft two search-ad variants supported by the landing page and policy. Return [{keywordGroup,headline,description,ruleId}]. Keep headlines at most 30 characters and descriptions at most 90 characters. Do not submit ads or change targeting, bids or budgets.',
['keywordGroup','headline','description','ruleId'], undefined, sample([firefox], { keywordGroups:['Firefox browser'], rules:[{id:'supported-claims',text:'Claims must be supported by the cited page.'}] })),The sample implements runScenario(), browser(), research(), and sample() as application functions. The pipeline below makes the actual SDK calls: delegation and introspection → required Memory/search → browser task or monitor → validation and saving. fields and sourceField define the application's output checks. The complete application helpers are in the package's runtime.ts.
Inspect the actual SDK pipeline
// browser() uses DoAnything; research() searches first; sample() labels public-demo inputs.
const firefox = 'https://www.mozilla.org/en-US/firefox/new/'
const manifesto = 'https://www.mozilla.org/en-US/about/manifesto/'
const privacy = 'https://www.mozilla.org/en-US/privacy/firefox/'
const support = 'https://support.mozilla.org/en-US/kb/get-started-firefox-overview-main-features'
// These copy rules become prompt context; server permissions and business checks remain separate.
const policy = {
version: 'demo-2026-09',
approvedClaims: ['Describe only features supported by the cited page.'],
forbiddenClaims: ['guaranteed security', '100% private', 'unverified pricing or performance'],
voice: 'concise and warm',
}
const sample = (pages: string[], business: JsonObject = {}): JsonObject => ({
dataset: 'public-demo', pages, policy, business,
notice: 'Business records are synthetic demonstration inputs. Referenced websites and SDK execution are real.',
})
// fields lists required output keys; sourceField identifies URL checks; memory enables GUMem calls.
const browser = (id: string, task: string, fields: string[], sourceField: string | undefined, input: JsonObject, memory = false): Scenario => ({
id, products: ['doAnything'], task, fields, sourceField, input, memory,
})
const research = (id: string, task: string, fields: string[], sourceField: string, queries: string[], input: JsonObject, memory = false): Scenario => ({
id, products: ['webSearch', 'doAnything'], task, fields, sourceField, queries, input, memory,
})import { QoniScopes, type JsonObject, type RunResult } from '@qoniai/qoni'
import { readFileSync } from 'node:fs'
import { getScenario, type Scenario } from './scenarios.js'
import { appendTrace, checkInputCoverage, cleanupDemoUser, createContext, delegate, handleInteraction, inputEntryCount, isolateDemoUser, object, readWithRetry, renderScreenshot,
save, saveArtifacts, searchHits, settled, settleRun, validateItems, withCleanup, type Context, type Options } from './runtime.js'
export async function runScenario(id: string, options: Options = {}, input?: JsonObject) {
// Load the task definition by ID; --input replaces its business inputs.
const scenario = getScenario(id)
const data = input ?? scenario.input
if (scenario.memory && data.dataset !== 'public-demo' && options.mode !== 'interactive' && !options.userId && !process.env.QONI_USER_ID) {
throw new Error('Business Memory writes require the current QONI_USER_ID; do not select an arbitrary bound user')
}
const context = await createContext(id, { ...options,
skipUserResolution: scenario.memory && data.dataset === 'public-demo' })
return withCleanup(context, async register => {
register('isolated demonstration user', () => cleanupDemoUser(context))
// Isolate demo preferences; business Memory belongs to the identified current user.
if (scenario.memory && data.dataset === 'public-demo') await isolateDemoUser(context)
return await executeScenario(context, scenario, data)
})
}
export async function executeScenario(context: Context, scenario: Scenario, input: JsonObject) {
const pages = input.pages
if (!Array.isArray(pages) || !pages.length || pages.some(page => typeof page !== 'string' || !/^https:\/\//.test(page))) {
throw new Error('Input pages must be an array of HTTPS URLs')
}
if (input.requiresLogin === true && context.mode !== 'interactive') {
throw new Error('Targets that require sign-in need --interactive and user-controlled login')
}
const memoryScopes = scenario.memory
? [QoniScopes.GUMEM_MEMORY_READ, QoniScopes.GUMEM_MEMORY_WRITE, QoniScopes.GUMEM_MESSAGE_WRITE] : []
// delegate() is an application helper around the SDK delegation methods.
const grant = await delegate(context, scenario.id, scenario.products, memoryScopes)
// Read the effective scopes; readWithRetry() retries only retryable read failures.
const { data: tokenInfo } = await readWithRetry(context, 'delegation introspection',
() => context.qoni.genauth.introspectDelegationToken({ token: grant.token }))
const info = object(tokenInfo)
if (info.active !== true) throw new Error('The delegation token is not active')
const audit = { grantId: grant.grantId, auditId: grant.auditId, scopes: info.scope }
let memory: unknown
if (scenario.memory) {
// A Session associates this conversation with the user; the app chooses sessionId.
const sessionId = `${scenario.id}-${Date.now()}`
await context.qoni.gumem.createSession({
token: grant.token, userId: context.userId, sessionId, title: scenario.id,
})
const preferences = object(input.business ?? {}).confirmedPreferences
if (Array.isArray(preferences) && preferences.length) {
// Store confirmed preferences only; sync: true requests synchronous processing.
await context.qoni.gumem.addMessages({ token: grant.token, userId: context.userId, sessionId, sync: true,
messages: [{ role: 'user', content: `Confirmed demonstration preferences: ${preferences.join('; ')}` }] })
}
// Recall relevant preferences for the later task prompt.
memory = (await readWithRetry(context, 'GUMem recall', () => context.qoni.gumem.recall({ token: grant.token, sessionId,
query: 'Confirmed preferences relevant to this task', details: true }))).data
save(context, 'memory.json', { sessionId, context: memory })
if (Array.isArray(preferences) && preferences.length && !preferences.every(value => JSON.stringify(memory).includes(String(value)))) {
throw new Error('Recall did not include the confirmed preferences just written by this demo')
}
}
if (scenario.products.includes('track')) return runMonitor(context, scenario, input, grant.token, audit)
let hits: ReturnType<typeof searchHits> = []
if (scenario.queries) {
// Web Search returns results[]; DoAnything receives these sources to inspect.
const search = await context.qoni.webSearch.run({ token: grant.token, prompt: scenario.queries, maxResultsPerQuery: 3 })
save(context, 'search-ref.json', { runId: search.id, audit })
await withCleanup(context, async register => {
register('Web Search run', () => search.cancel('Documentation demonstration cleanup'))
const result = await settleRun(context, search)
save(context, 'search-result.json', result)
settled(result)
hits = searchHits(result.output)
})
}
// One-per-entry scenarios request exactly one item per input entry; others at most two.
const requiredItems = inputEntryCount(scenario.id, input)
// Assemble the task, inputs, search sources, and Memory as application-defined context.
const prompt = [scenario.task, `Task inputs: ${JSON.stringify(input)}`,
`Search sources: ${JSON.stringify(hits)}`, `Confirmed memory: ${JSON.stringify(memory ?? null)}`,
`Actual collection time: ${new Date().toISOString()}`,
requiredItems === undefined
? 'Inspect the supplied sources. Return at most two items in the requested JSON array, without prose or Markdown.'
: `Inspect the supplied sources. Return exactly ${requiredItems} item${requiredItems === 1 ? '' : 's'} in the requested JSON array, one per input entry, without prose or Markdown.`,
'Keep synthetic demonstration data identified as synthetic. Do not send messages, publish, pay, edit accounts or submit forms.',
input.requiresLogin === true ? 'Request user sign-in through an interaction when required; never enter credentials yourself.' : 'Public demonstration sources only; do not sign in.',
].join('\n\n')
// Start the Agent with this grant; capture receives delivered screenshots, not every step.
const run = await context.qoni.doAnything.run({ token: grant.token, prompt, capture: { screenshots: true } })
save(context, 'run-ref.json', { runId: run.id, session: run.sessionRef, audit })
let result: RunResult
const trace = (event: { type: string; data: unknown }) => {
context.eventCounts[event.type] = (context.eventCounts[event.type] ?? 0) + 1
if (['progress','message','done'].includes(event.type)) appendTrace(context, event)
if (event.type === 'browserLiveUrlChanged') {
const liveUrl = object(event.data).liveUrl
if (typeof liveUrl === 'string') context.browserUrl = liveUrl
}
}
return withCleanup(context, async register => {
register('DoAnything run', () => run.cancel('Documentation demonstration cleanup'))
if (context.delivery === 'events') {
// --events streams updates; wrap interaction data in an SDK handle for user handling.
for await (const event of run.events({ signal: AbortSignal.any([context.abort.signal, AbortSignal.timeout(context.timeoutMs)]) })) {
trace(event)
if (event.type === 'screenshot') renderScreenshot(context, event.image)
if (event.type === 'interaction') await handleInteraction(context, run.interactionHandle(event.data))
}
result = await settleRun(context, run)
} else {
// Callback mode receives this run's events inside wait; helpers save images and ask the user.
result = await settleRun(context, run, { onEvent: trace,
onScreenshot: (image, index) => renderScreenshot(context, image, index),
onInteraction: interaction => handleInteraction(context, interaction) })
}
save(context, 'result.json', result)
settled(result)
await saveArtifacts(context, result)
if (scenario.id === 'landing-page-audit-agent' && context.screenshots === 0) throw new Error('The landing-page audit did not deliver the requested screenshot')
// The app checks required fields and source URL formats; a reviewer still checks facts.
const items = validateItems(result.output, scenario.fields, scenario.sourceField)
// Scenarios that require one item per input entry are checked against the input.
checkInputCoverage(scenario.id, items, input)
const report = { scenario: scenario.id, dataset: input.dataset, passed: true, runId: run.id,
status: result.status, items, audit, screenshots: context.screenshots, interactions: context.interactions,
events: context.eventCounts, artifactIds: result.artifacts.map(artifact => artifact.id) }
save(context, 'report.json', report)
return report
})
}
async function runMonitor(context: Context, scenario: Scenario, input: JsonObject, token: string, audit: JsonObject) {
// Track creates a monitor with targets, extraction fields, and hourly scheduling.
const monitor = await context.qoni.track.create({ token, prompt: scenario.task,
targetUrls: input.pages, extractionSchema: { heading: 'string', source_url: 'string' },
tickInstructions: `Open the target URLs and read the actual visible heading. Return a JSON object with heading and source_url. ${scenario.task}`,
triggerDsl: { on: 'change' }, schedule: { kind: 'interval', intervalSeconds: 3600 } })
save(context, 'monitor-ref.json', { id: monitor.id, audit })
return withCleanup(context, async register => {
register('Track monitor', () => monitor.delete())
const definition = await monitor.get()
save(context, 'monitor-definition.json', definition)
if (object(definition.schedule).intervalSeconds !== 3600) throw new Error('Track did not persist the requested schedule interval')
// Run one tick and inspect its extraction by runId; completed alone does not prove success.
const tick = await monitor.runNow()
save(context, 'tick.json', tick)
if (tick.state !== 'completed') throw new Error(`Track execution failed: ${tick.state} / ${tick.error ?? ''}`)
const runId = tick.runId
if (typeof runId !== 'string') throw new Error('Track tick did not return a runId')
const detail = await monitor.run(runId)
save(context, 'tick-detail.json', detail)
if (detail.state !== 'completed' || !detail.extracted || !Object.keys(object(detail.extracted)).length) {
throw new Error('Track did not extract page data')
}
const extracted = object(detail.extracted)
if (typeof extracted.heading !== 'string' || !extracted.heading.trim() ||
typeof extracted.source_url !== 'string' || !/^https:\/\//.test(extracted.source_url)) {
throw new Error('Track extraction is missing a heading or source URL')
}
const normalizeUrl = (value: string) => { const url = new URL(value); url.hash = ''; return url.href.replace(/\/$/, '') }
if (!(input.pages as string[]).some(url => normalizeUrl(url) === normalizeUrl(String(extracted.source_url)))) {
throw new Error('The Track source URL is not a configured target')
}
// Check persisted pause/resume state; withCleanup() deletes the monitor on exit.
await monitor.pause()
if ((await monitor.get()).status !== 'paused') throw new Error('Track did not persist the paused state')
await monitor.resume()
if ((await monitor.get()).status !== 'active') throw new Error('Track did not persist the active state')
const report = { scenario: scenario.id, dataset: input.dataset, passed: true, monitorId: monitor.id,
runId, state: detail.state, outcome: detail.outcome, extracted: detail.extracted, audit }
save(context, 'report.json', report)
return report
})
}
export function inputFile(): JsonObject | undefined {
const index = process.argv.indexOf('--input')
return index >= 0 ? object(JSON.parse(readFileSync(process.argv[index + 1], 'utf8'))) : undefined
}Results are written to output/paid-search-copy-agent/report.json. report.items contains keyword groups, headlines, descriptions, and rule IDs for ad-review staff, with fields keywordGroup, headline, description, ruleId. audit links the grant ID, audit ID, and effective scopes; http.json records redacted request statuses. The application parses DoAnything output and checks required fields. A business reviewer still assesses the content against the original sources.
Data and memory boundaries
This scenario touches four kinds of data; GUMem is not used here:
- Versioned rules: forbidden claims, brand rules, compliance limits — managed by version in your policy store, with every variant referencing a rule id and version.
- Business state: copy variants, review outcomes, per-keyword-group performance history — archived to your campaign records and analytics store for review and traceability.
- Audit records: the delegation and behavior chain formed by
grantIdandauditId— maintained by GenAuth. - User Memory (optional): only long-term personal preferences a user or reviewer has explicitly confirmed belong in GUMem; the inputs here are team-level rules and business data, so this scenario neither recalls nor writes back by default.
Failure handling
| Situation | Recommended handling |
|---|---|
| Ad platform login state expires | Suspend the task, notify the user to sign in again, and resume from the checkpoint. |
| Landing page extraction fails or content mismatches keywords | Treat it as a failure and replay the session recording; never write copy from guessed page content. |
| Bid or budget change request outside the delegated scope | Reject and record it; the attempted action remains visible in the audit chain. |
| A variant lacks a keyword group or rule id | App-side validation drops the variant and the review notes record how many were dropped and the policy version applied. |
Production notes
Do not submit ads or change budgets automatically. All variants should go to a draft or review queue. Cap read frequency against the ad platform to avoid triggering risk controls; product claims in copy must be supported by the landing page or authorized materials — unsupported claims do not enter drafts. When the claims policy changes, publish a new version in the policy store so stale rules never constrain new variants.
Next steps
- Read the Quickstart to run the shortest path for Agent identity and delegation.
- Read Authorization and browser sandbox for the security boundaries of controlled sessions.
- Continue with the SEO content planner agent for an adjacent scenario.