Skip to content

Sales lead agent ​

This page explains how a sales lead agent uses WebSearch to collect public signals about target companies (news, funding, hiring, tech stack), scores them deterministically on the app side against a versioned ICP, and writes sourced lead summaries back to the CRM as draft records. After reading it, you will understand why collecting public signals only needs a silently issued runtime credential, why scoring lives on the app side rather than in the model, and why ICP profiles and interaction history stay canonical in the CRM.

Use case ​

Sales teams need to quickly understand target companies, contacts, recent news, technology signals, and potential entry points. These signals are scattered across company sites, press releases, and job pages; researching each account manually cannot keep pace with a growing lead list. The Agent's output is a lead summary with scoring evidence plus draft CRM records; outreach is always performed by the seller.

Typical triggers:

  • A marketing campaign delivers a batch of new leads that must be enriched and prioritized before follow-up.
  • A target account shows a buying signal (funding, expansion, relevant job postings) and the entry point must be refreshed promptly.
  • A new quarter starts, and the existing lead pool must be re-ranked against the latest ICP criteria.

Engineering challenges ​

  • Signal quality: press releases, retellings, and stale pages are noisy — "they're hiring DevOps" may be a year-old posting. Every signal that enters scoring needs a source URL and capture time, or the score is just storytelling.
  • Scoring consistency: ICP criteria evolve with closed-won experience; unversioned criteria make batches incomparable — "80 points last quarter" and "80 points this quarter" no longer mean the same thing.
  • CRM data sovereignty: the single source of truth for ICP profiles and interaction history is the CRM. Copying them into a second store drifts immediately — enrichment results may only go back as draft records, with official changes confirmed by the seller.

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 searches company sites, news, job pages, and public sources, keeping a source URL and capture time per signal.
GUMemNot usedICP profiles and interaction history stay canonical in the CRM, read directly by your app and injected by version into scoring; no second source of truth in Memory.

Sales lead 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 from the seller in the Console:

  • Public reads: issue a runtime credential silently with delegateToken (products: ['webSearch']). The credential is short-lived and revocable at any time; every issuance and search enters the audit chain.
  • Signed-in targets or write actions: CRM reads and draft writes are done by your app directly with its own CRM API credentials, not through Qoni delegation. Only if enrichment needs the Web Agent to sign in to a third-party data source (for example, a paid database) do you switch to mode: 'interactive' so the seller approves in Qoni Console. The example on this page includes no such target.

Note: this sample requests webSearch, doAnything product permissions. Your application and downstream services must configure and enforce business limits such as target 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 ​

Sales lead agent workflow

  1. The seller enters a target company or submits a lead list for enrichment.

  2. Your app silently issues a runtime credential and reads the current ICP criteria version and the accounts' interaction history from the CRM.

  3. Web Agent collects public signals from company sites, news, and job pages through WebSearch, keeping a source URL and capture time per signal.

    Checkpoint: When a target page shows a login wall or CAPTCHA, skip the source and record it; escalate to the seller to decide on a manual look — never attempt a bypass.

  4. Your app parses and validates the signals: anything missing a source URL or capture time is dropped and never enters scoring.

  5. Your app scores the leads deterministically against the ICP criteria, keeping the rationale and sources per signal.

    Checkpoint: Every signal that enters scoring must have a source; unsourced speculation never contributes to a score.

  6. Your app writes the scored summary, outreach angles, and source list back to the CRM as draft records, which become official data only after seller confirmation.

    Checkpoint: Changes to official CRM records require seller confirmation; the Agent never overwrites manually maintained fields.

  7. The seller decides the follow-up order from the summary; for email outreach, a draft is sent only after the seller confirms it.

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 organization information against a synthetic browser/open-web ICP. 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 -- sales-lead-agent
# Supply your own inputs
npm run case -- sales-lead-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'

// Research public company signals for a sales-review draft.
const report = await runScenario('sales-lead-agent', cliOptions(), inputFile())
// report.items: companies, signals, sources, and collection timestamps without CRM writes.
// Validated fields: company, signal, sourceUrl, capturedAt.
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
// Research public company signals for a sales-review draft.
research('sales-lead-agent',
  'Use the provided public sources and synthetic ICP to list evidence for a company-research draft. Return [{company,signal,sourceUrl,capturedAt}]. Do not collect private contacts, contact the company, send messages or update CRM.',
  ['company','signal','sourceUrl','capturedAt'], 'sourceUrl', ['Mozilla official organization mission products'], sample([manifesto], { companies:['Mozilla'],icp:{industry:'browser and open-web technology'},interactionHistory:[] })),

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/sales-lead-agent/report.json. report.items contains companies, signals, sources, and collection timestamps without CRM writes, with fields company, signal, sourceUrl, capturedAt. 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: ICP criteria, industry preferences, and scoring weights — managed by version in the CRM or a config store, with every scoring batch referencing the ICP version; a criteria change is a version bump.
  • Business state: public signals, scoring results, outreach angles, and draft records — written back to the CRM as drafts; ICP profiles and interaction history stay canonical in the CRM, with no second source of truth.
  • Audit records: the behavior chain of credential issuance and every search — maintained by GenAuth.
  • User Memory: this scenario does not use GUMem. Profiles, interactions, and scoring criteria are CRM data that needs team sharing and version reconciliation, not one user's session preferences.

Failure handling ​

SituationRecommended handling
A target page shows a login wall or CAPTCHASkip the source and record it; escalate to the seller to decide on a manual look — never attempt a bypass.
Public signals are too thin to support a scoreState the insufficient evidence and lower the confidence; never fabricate a scoring rationale.
A signal lacks a source URL or capture timeApp-side validation drops the signal and the summary notes how many were dropped.
A CRM draft conflicts with a manually maintained fieldThe draft stays in draft state with the conflict flagged; the seller arbitrates, and the Agent never overwrites manual fields.

Production notes ​

Do not let the Agent send external email automatically; the timing and wording of outreach remain the responsibility of the seller who confirms the draft. CRM writes stay limited to draft records, and official customer data changes must be confirmed by the seller. Cap the fetch frequency against target sites; for a long-lived watch on a target account's site or job pages, see the Track shape in the Vendor monitor agent.

Next steps ​