Skip to content

Competitive intelligence agent ​

This page explains how a competitive intelligence agent uses Web Agent's WebSearch to periodically collect competitor launches, pricing, docs updates, hiring signals, and news, validates sources and confidence on the app side, writes the results into a structured intel store, and assembles a traceable intelligence brief. After reading it, you will understand why collecting public information only needs a silently issued runtime credential, how long-running tasks reconnect from run.id, and why historical judgments belong in a versioned intel store rather than Memory.

Use case ​

Product, marketing, or strategy teams need to track competitor launches, pricing changes, docs updates, and news. These signals are scattered across websites, changelogs, hiring pages, and press coverage; manual aggregation is slow and tends to treat second-hand retellings as first-hand facts, and a brief without uniform source labels cannot be verified afterwards.

Typical triggers:

  • The weekly or biweekly intelligence brief is due and must cover a fixed competitor list.
  • After a competitor launch event or major release, its impact on the team's roadmap must be assessed quickly.
  • A competitor's hiring page shows roles signaling a new direction that should feed strategic judgment.

Engineering challenges ​

  • Second-hand retellings vs. first-hand facts: the same "competitor shipped X" carries very different credibility from an official changelog versus a press retelling. A brief that does not tier its sources spreads speculation as conclusion, with no way to correct it later.
  • Increment detection: what the team actually wants is "what's new this cycle." Without a versioned store of historical items, every brief re-reports known facts or misses quiet changes.
  • Interrupt recovery for long runs: a collection cycle covering a dozen competitors can run for tens of minutes, and process restarts, deploys, or timeouts will cut it off. A collection task that cannot reconnect has to be re-run from scratch.

Module composition ​

ModuleRoleNotes
GenAuthCoreSilent 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 — see "When interactive consent is needed" below.
Web AgentCoreWebSearch collects public signals across sources; run() returns a reconnectable handle, so long tasks save run.id and attach() at any time.
GUMemNot usedIntel items, sources, and confidence are shared team business data — they go to a structured intel store; historical judgments live there too, versioned, invalidated when disproven rather than deleted. Memory plays no part in this scenario.

Competitive intelligence agent architecture

Every product call requires a GenAuth delegate token; public read-only scenarios are covered by silent delegation. This scenario reads publicly visible pages only and needs no interactive confirmation in the Console:

  • Public reads: issue a runtime credential silently with delegateToken (products: ['webSearch']). The credential is short-lived and revocable at any time, re-issued per cycle on schedule; every issuance and search enters the audit chain.
  • Signed-in targets or write actions: this scenario explicitly never signs in to competitor products, registers trial accounts, or bypasses access controls. If you genuinely need signed-in competitor research, that is a different scenario and should use mode: 'interactive' delegation — see the Authenticated competitive messaging agent.

Note: this sample requests webSearch, doAnything product permissions. Your application and downstream services must configure and enforce business limits such as competitor domain lists and fetch frequency. Product scopes and prompt rules do not enforce those detailed limits; this sample does not configure them. See Delegate token and attenuation.

Workflow ​

Competitive intelligence agent workflow

  1. The team defines the competitor list, topics of interest, and brief cadence, maintained in a versioned collection playbook.

  2. Your app silently issues a runtime credential and loads the current playbook version and already-reported facts from the intel store.

  3. Your app starts the WebSearch collection and saves the run.id for reconnection.

    Checkpoint: After a process restart or a wait() timeout, reconnect to the same task with qoni.webSearch.attach(run.id, { token }) instead of re-running the whole cycle.

  4. Web Agent searches across sources for launch, pricing, docs, hiring, and news signals, keeping a source URL per item.

  5. Your app parses and validates the results: unsourced items are dropped; key conclusions with corroboration < 2 are marked single_source: true and routed to a needs-review list instead of the brief body; contradictory items are kept side by side, never merged silently.

    Checkpoint: Key conclusions backed by a single source go to the needs-review list and only graduate to brief facts after human verification; when sources contradict each other, keep the contradiction on record instead of picking a side.

  6. Validated items are written to the structured intel store together with the playbook version; comparison against historical items highlights the true increment.

  7. Your app assembles the brief (facts, inferences, and suggestions layered separately) with sources, confidence labels, and the audit id for the analyst.

    Checkpoint: Every conclusion in the brief should trace back to a concrete item and source in the intel store; conclusions 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 official Firefox product and privacy information with Mozilla Firefox as the competitor. 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:

bash
cd examples/qoni
npm ci
npm run case -- competitive-intelligence-agent
# Supply your own inputs
npm run case -- competitive-intelligence-agent --input /path/to/input.json

Set 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:

ts
import { cliOptions } from '../runtime.js'
import { inputFile, runScenario } from '../run-case.js'

// Search and cross-check competitor claims while retaining uncertainty.
const report = await runScenario('competitive-intelligence-agent', cliOptions(), inputFile())
// report.items: competitors, claims, sources, timestamps, confidence, and corroborating-source counts.
// Validated fields: competitor, claim, sourceUrl, capturedAt, confidence, corroboration.
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.

ts
// Search and cross-check competitor claims while retaining uncertainty.
research('competitive-intelligence-agent',
  'Use the supplied search results and official pages to produce sourced product intelligence. Return [{competitor,claim,sourceUrl,capturedAt,confidence,corroboration}]. Set corroboration to the number of actual cited independent sources, and retain uncertain claims as low confidence. Do not invent publication dates.',
  ['competitor','claim','sourceUrl','capturedAt','confidence','corroboration'], 'sourceUrl', ['Mozilla Firefox browser privacy official'], sample([firefox,privacy], { competitors:['Mozilla Firefox'], playbookVersion:'demo-v1' })),

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
ts
// 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,
})
ts
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/competitive-intelligence-agent/report.json. report.items contains competitors, claims, sources, timestamps, confidence, and corroborating-source counts, with fields competitor, claim, sourceUrl, capturedAt, confidence, corroboration. 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 belongs in GUMem:

  • Versioned rules: the competitor list, topics of interest, and confidence-tiering standards — managed by version in the collection playbook, with every brief referencing the playbook version.
  • Business state: intel items, source pointers, confidence labels, and historical judgments — kept in the structured intel store; disproven judgments are invalidated and linked to their replacements, so evolution stays traceable.
  • Audit records: the behavior chain of credential issuance and every search — maintained by GenAuth.
  • User Memory: this scenario does not use GUMem. Intelligence is shared team business data, not one user's preferences; putting it in Memory loses version reconciliation and team sharing.

Failure handling ​

SituationRecommended handling
The collection run is interrupted (restart, deploy, timeout)Reconnect with attach() using the saved run.id; never re-run the whole cycle.
A source page is unreachable or taken downKeep the failure record and lower the confidence of related items; never present cached content as current fact.
Sources contradict each other on the same factWrite the sources and differences side by side, mark them pending human judgment, and do not merge into a single conclusion.
A key conclusion has only one supporting source (corroboration < 2)Mark it single_source: true and route it to the needs-review list; it graduates to a brief fact only after human verification.
An output item lacks a sourceApp-side validation drops the item and the brief notes how many were dropped.

Production notes ​

Market analysis should separate facts, inferences, and suggestions, and never present an inference as a settled conclusion; the layering happens when your app assembles the brief, based on the per-item sources and confidence in the intel store. Collection covers only publicly visible pages — no account registration, no bypassing access controls — and fetch frequency against target sites should be capped. For fixed targets such as pricing pages that need a long-lived watch, see the Track shape in the Vendor monitor agent.

Next steps ​