Campaign brief agent
This page explains how a campaign brief agent runs public market research under a silent grant, combines app-injected product materials, target audiences, and past campaigns with sourced market facts into a brief draft, and streams research progress back as typed events. After reading it, you will understand when this scenario actually needs interactive consent, why every external fact must carry a source, and why data like product materials belongs in explicit inputs rather than Memory.
Use case
Marketing teams preparing a campaign need to summarize goals, audience, messaging, competitor context, channel ideas, and risk boundaries. Internal materials are scattered across docs, CRM, and campaign workspaces, while public market information needs source-by-source verification — assembling one brief by hand means repeated searches across several systems.
Typical triggers:
- Quarterly campaign planning kicks off, and a first brief is due within a week.
- A product enters a new market or audience segment, and competitor context and channel ideas must be summarized.
- A campaign retrospective ends, and its lessons must feed into the next brief.
Engineering challenges
- Facts and assumptions share one document: a brief mixes sourced market facts, internal judgment, and unverified assumptions. A brief that does not separate the three passes guesses downstream as facts, so every item must carry a source or be marked as an assumption.
- Public information goes stale fast and contradicts itself: market sizing and competitor moves differ between sources, so every external data point needs a collection time and source URL, and conflicting sources must be presented side by side rather than silently arbitrated.
- Internal inputs and public queries must stay separated: product materials, audience profiles, and unreleased information are injected explicitly by your app; the moment the task carries them into a public search query, that is a leak.
Module composition
| Module | Role | Notes |
|---|---|---|
| GenAuth | Core | Silent delegation issues the short-lived runtime credential every product call requires; interactive consent is only needed when login state or write actions enter the picture. Credentials remain short-lived, revocable, and audited. |
| Web Agent | Core | Scans market trends and public reports with WebSearch, then researches competitor pages one by one, keeping a source URL and collection time for every external fact. |
| GUMem | Not used | Product materials, target audiences, and past campaign lessons are business data — your app reads them from docs, CRM, and campaign records and injects them into the task explicitly. They are not Memory. |
When interactive consent is needed
Every product call requires a GenAuth delegate token; public read-only scenarios are covered by silent delegation. The default path here only does public web research: your app exchanges the GenAuth user ID bound to your Qoni credentials for a runtime credential, with no user redirect. The credential is explicit, short-lived, and revocable, covering only public reads and task execution; editing internal materials, exporting customer records, and publishing externally sit outside every grant.
Upgrade to interactive consent (mode: 'interactive') when:
- The Agent should read authorized pages of an internal docs library in a controlled session, instead of receiving app-injected summaries.
- Research needs signed-in pages or paid data sources.
The upgrade works the same as in other scenarios: mode: 'interactive' plus a redirectUri, with the user confirming in Qoni Console and your server exchanging the grant via completeDelegateToken — see the Quickstart for the full flow, and the Product launch messaging agent for a complete controlled-internal-reads example.
Workflow
The user selects a product, audience, and campaign goal.
Your app obtains a runtime credential through a silent grant (public research, no user redirect).
Your app reads product materials, audience profiles, and past lessons from docs, CRM, and campaign records, injecting them into the task as explicit inputs.
Checkpoint: Internal materials are input only; unreleased product information must not appear in the query content of any subsequent public web task.
Web Agent scans market trends and public reports through WebSearch, keeping a source URL and publish date per result.
Web Agent researches competitor pages one by one and cross-checks market information; research progress streams back as events.
The Agent combines internal and external inputs into a brief draft: goals, audience, messaging, channel ideas, assumptions, and risk boundaries.
The Agent returns the brief draft, a source list, and open questions with an audit id; after app-side validation they go to the marketing lead.
Checkpoint: Every external fact in the brief should trace back to a concrete source; conclusions without supporting evidence should be marked as assumptions, not stated as facts.
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 combines official Firefox sources with a synthetic audience and awareness-campaign goal. 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. Web Search first supplies results[], which DoAnything then reads and analyzes. 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 -- campaign-brief-agent
# Supply your own inputs
npm run case -- campaign-brief-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'
// Search public sources and draft a brief that separates facts from assumptions.
const report = await runScenario('campaign-brief-agent', cliOptions(), inputFile())
// report.items: brief sections, statements, fact/assumption labels, and sources.
// Validated fields: section, statement, kind, sourceUrl.
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.
// Search public sources and draft a brief that separates facts from assumptions.
research('campaign-brief-agent',
'Use the pages, search results and synthetic campaign inputs to draft a campaign brief. Return [{section,statement,kind,sourceUrl}], where kind is fact or assumption. Every item must include sourceUrl. Cite facts; label campaign recommendations as assumptions and cite the product source that informed them, without presenting the recommendation as a claim made by that source. Do not publish or edit anything.',
['section','statement','kind','sourceUrl'], 'sourceUrl', ['Mozilla Firefox features official'], sample([firefox], { product:'Firefox', audience:'People comparing desktop browsers', goal:'Draft an awareness campaign' })),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/campaign-brief-agent/report.json. report.items contains brief sections, statements, fact/assumption labels, and sources, with fields section, statement, kind, sourceUrl. 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 and source URL formats. 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: if brand voice and forbidden language need to constrain the brief's wording, keep them in your policy store and inject them per version.
- Business state: product materials, audience profiles, past campaign lessons, and this brief's conclusions — read from docs, CRM, and campaign records by your app, injected explicitly, with conclusions written back to the same business stores.
- Audit records: the delegation and behavior chain formed by
grantIdandauditId— maintained by GenAuth. - User Memory (optional): only long-term personal preferences a user has explicitly confirmed belong in GUMem; audience profiles and campaign lessons are team-level business data, not personal memory, so this scenario neither recalls nor writes back by default.
Failure handling
| Situation | Recommended handling |
|---|---|
| Internal inputs fail to load | Treat it as missing input and prompt the user to supply it; never fall back to guessing. |
| Public sources contradict each other | List the conflicting sources and timestamps side by side and mark them as open questions instead of arbitrating silently. |
| The event stream disconnects | The SDK reconnects and resumes per sseMaxRetries; beyond the limit, treat it as a failure and replay the events received so far. |
| An item is labelled a fact but lacks a source | App-side validation demotes it to an assumption or drops it, and the brief notes how many were handled. |
Production notes
Do not send unreleased product information into public web tasks. External claims should be marked for human confirmation. External data in the brief — market sizing, competitor moves — should retain collection timestamps so stale information is never cited as current. Limit internal-input reads to what this campaign needs; never inject a full CRM export into the task.
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 Product launch messaging agent for an adjacent scenario.