Influencer vetting agent
This page explains how an influencer vetting agent uses public web search to vet candidate creators' public profiles, past content, and brand safety risks, and produces a due-diligence report for human decision-making. After reading it, you will understand which modules this scenario needs, when interactive consent is actually needed, and why risk rules and due-diligence conclusions belong in a versioned review system rather than Memory.
Use case
Marketing teams need to evaluate creator audience fit, past content, brand safety risks, and campaign fit. Candidate lists often run to dozens of creators; manually digging through each platform's content history is slow and leaves no evidence — and a brand safety verdict without sources cannot be re-verified.
Typical triggers:
- A new campaign's candidate list is drafted, and batch due diligence must finish before outreach.
- A contracted creator is rumored to have posted controversial content, and their recent public content must be re-checked.
- Brand safety rules change, and the existing candidate pool must be re-screened.
Engineering challenges
- Public information is scattered and time-sensitive: controversy leads spread across social, news, and video platforms. Missed searches miss real risks, while citing stale coverage produces false verdicts — both coverage and recency must be traceable to be reviewable.
- Risk verdicts must be re-verifiable: an unsourced "this candidate is a brand safety risk" cannot support a partnership decision, and cannot defend itself when the verdict is challenged or the candidate disputes it; every risk must land on a concrete public source.
- The boundary around personal data is sensitive: due diligence handles a natural person's public information, and conclusions may only serve this evaluation — accumulating long-term person risk profiles as general-purpose data is itself a new compliance risk.
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 are short-lived and revocable. |
| Web Agent | Core | Searches candidates' public social profiles, personal sites, and press coverage through WebSearch, keeping a source link per lead. |
| GUMem | Not used | Brand safety rules and forbidden categories are versioned policy — keep them in your policy store and inject them per version; due-diligence conclusions are archived by version in your review system. Person risk profiles never accumulate in general-purpose Memory. |
When interactive consent is needed
Every product call requires a GenAuth delegate token; public read-only scenarios are covered by silent delegation. This scenario only reads candidates' public data, so silent delegation with products: ['webSearch'] covers it — no site sign-in involved; credentials are short-lived and revocable at any time. Only the following cases require escalating to interactive consent, confirmed by the user in Qoni Console:
- Signed-in marketplace or CRM data such as rates and partnership history is needed.
- A controlled browser session must open signed-in platform pages for extra evidence.
Note: this sample requests webSearch, doAnything product permissions. Your application and downstream services must configure and enforce business limits such as candidate list ranges, search boundaries. 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 the campaign and candidate creator list.
GenAuth issues a silent delegation credential for this task, covering only the
webSearchproduct.Your app loads the current version of brand safety rules, target audiences, and forbidden categories from the versioned review system and injects them into the search task.
Web Agent searches each candidate's public social profiles, personal sites, and press coverage through WebSearch, keeping a source link per lead.
Checkpoint: Leads without a source link are dropped; WebSearch only reads public pages — no sign-in, no outreach.
Your app organizes each candidate's risks and audience fit against the rules, each conclusion with a public source.
The Agent returns the due-diligence report — fit summaries, risks, a source list, and open questions for humans — with the policy version and an audit id; conclusions are archived by version to the review system, never written to general-purpose Memory.
Checkpoint: Every risk conclusion should trace back to a concrete public source; judgments without evidence should not enter the deliverable.
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 researches Mozilla public leadership information to inspect Mitchell Baker’s public browser-industry background. 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 -- influencer-vetting-agent
# Supply your own inputs
npm run case -- influencer-vetting-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'
// Review public candidate information against explicit criteria.
const report = await runScenario('influencer-vetting-agent', cliOptions(), inputFile())
// report.items: candidates, findings, and sources for a partnership reviewer.
// Validated fields: candidate, finding, 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.
// Review public candidate information against explicit criteria.
research('influencer-vetting-agent',
'Review the public professional information in the provided results and pages for this demonstration candidate. Return [{candidate,finding,sourceUrl}]. Distinguish evidence from assumptions. Do not infer protected characteristics, collect private contact data, contact anyone, or make a hiring decision.',
['candidate','finding','sourceUrl'], 'sourceUrl', ['Mitchell Baker Mozilla public professional biography'], sample(['https://www.mozilla.org/en-US/about/leadership/'], { candidates:['Mitchell Baker'], criteria:['public browser-industry expertise'] })),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/influencer-vetting-agent/report.json. report.items contains candidates, findings, and sources for a partnership reviewer, with fields candidate, finding, 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; none of them needs GUMem:
- Versioned rules: brand safety rules, target audience definitions, forbidden categories — managed by version in your policy store, with every risk conclusion citing a rule version.
- Business state: due-diligence reports, risk leads, and source lists — archived by rule version to your review system for review and dispute handling.
- Audit records: the delegation and behavior chain formed by
grantIdandauditId— maintained by GenAuth. - User Memory (optional): this scenario neither recalls nor writes back by default; person risk profiles explicitly never accumulate in general-purpose Memory — candidates' public information serves this due-diligence run only and is handled by your data retention policy afterwards.
Failure handling
| Situation | Recommended handling |
|---|---|
| A finding lacks a source link | App-side validation drops the lead; better no verdict than an unsourced one. |
| A public source page is deleted or unreachable | Keep the failure record and mark related risk items as having incomplete basis; never substitute cached content. |
| WebSearch cannot cover a candidate | Mark them as having insufficient data and hand them to a human for follow-up; never fill in conclusions by speculation. |
| Search results point to a namesake | Mark the identity as unconfirmed and have a human verify before the finding enters the report. |
Production notes
Do not infer sensitive attributes or make automatic rejection decisions. Final partnership decisions should stay with humans. The Agent only reads public web pages — it never messages, comments, or initiates outreach to creators; candidate personal data is limited to publicly visible information, used only for this due-diligence run, and never accumulated into general-purpose profiles. Searches should respect the source sites' terms of service.
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 Review mining agent for an adjacent scenario.