Skip to content

Event follow-up agent ​

This page explains how an event follow-up agent, under delegated user authority, reads attendee lists, interaction records, and CRM notes, combines them with public company context into segmented follow-up drafts, and writes the drafts back to the CRM as draft records awaiting seller confirmation. After reading it, you will understand which permission boundaries this scenario needs, why the CRM is the canonical source for contact facts, and why GUMem only holds seller-confirmed tone preferences.

Use case ​

Marketing and sales teams need to quickly organize attendees after webinars, conferences, or in-person events and create personalized follow-up. Attendee lists are scattered across event platforms and the CRM, and researching each company before writing follow-up often drags past the point where the event is still fresh — while handing a script full CRM read-write access carries far more risk than the job requires.

Typical triggers:

  • Within 24 hours after a webinar, the first follow-up round must go out segmented by attendee engagement.
  • A trade show produces a batch of business cards and badge scans that need company context before handoff to sales.
  • A quarterly event review needs to reconcile which attendees already exist in the CRM and which are new contacts.

Engineering challenges ​

  • The freshness window: event momentum decays within 24–48 hours, and manual company research cannot keep up; but skipping fact verification for speed makes personalized content wrong — and wrong personalization damages the relationship.
  • The fact boundary of personalization: a line like "congrats on your Series B" must be backed by a public source; unsourced speculation, once sent, is worse than a template email.
  • CRM data sovereignty: the single source of truth for segments, event history, and contact facts is the CRM. Copying them into a second store drifts immediately — the Agent's output must go back to the CRM as draft records, promoted to official data only after seller confirmation.

Module composition ​

ModuleRoleNotes
GenAuthCoreAuthorized reads of the event platform and CRM: interactive delegation, read-only lists and notes, short-lived revocable credentials, and no send or official-field-write capability.
Web AgentCoreSigns in to the event platform or CRM to read the list and engagement (Profiles reuses login state), and fills in public company context with WebSearch, keeping a source per fact.
GUMemOptionalOnly stores seller-confirmed long-term tone preferences (for example, "no exclamation marks in follow-ups"). Segments, event history, and contact facts stay canonical in the CRM — no second source of truth in Memory.

Event follow-up agent architecture

Permission and delegation boundaries ​

The Agent holds no inherent permissions. The effective authority for each follow-up task is the intersection of three sets: what the user actually holds ∩ what was explicitly delegated ∩ what the enterprise has approved. Applied to this scenario:

  • The delegated scope covers only "read the current event's attendee list, interaction records, and related CRM notes" — no sending email, changing official CRM stages, or editing contact fields.
  • Event platform and CRM sign-in are high-risk operations and must use mode: 'interactive': the user approves in Qoni Console, and the credential is exchanged in a server-side callback.
  • Attendee personal information is limited to what is publicly visible; the delegation does not cover customer-privacy records beyond the list.
  • Out-of-scope attempts (for example, reading another event's list or rewriting official CRM records) are rejected and recorded — the audit chain covers all attempts.

Note: this sample requests doAnything product permissions. Your application and downstream services must configure and enforce business limits such as platform domains and event scope. Product scopes and prompt rules do not enforce those detailed limits; this sample does not configure them. See Delegate token and attenuation.

Workflow ​

Event follow-up agent workflow

  1. The user selects an event and a follow-up goal (for example, first-round thanks or sales-lead handoff).

  2. Your app starts interactive delegation; the user approves in Qoni Console, and the server-side callback exchanges the credential.

  3. Your app loads the current version of the follow-up playbook (segmentation criteria, brand voice, follow-up rules) from your policy store and injects it into the task.

  4. Web Agent signs in to the event platform and CRM to read the attendee list, interaction records, and related notes — the CRM is the canonical source for these facts.

    Checkpoint: When a login wall, CAPTCHA, or risk-control page appears, the Web Agent should escalate to a human instead of silently bypassing it.

  5. Web Agent researches each attendee's company through WebSearch, collecting only publicly visible information and keeping source URLs.

  6. The Agent segments attendees by engagement and drafts one follow-up per segment, with every company fact carrying its source.

  7. Your app validates the drafts (unsourced facts are cut) and writes them back to the CRM as draft records, with a source list and audit id, awaiting seller confirmation.

    Checkpoint: Every company fact in a draft should trace back to a public source; personalized content without evidence should not enter the draft.

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 supplies synthetic workshop segments and notes, then uses the Firefox product page for product facts. The SDK reads real public pages; business inputs in scenarios.ts are labeled public-demo.

This demo does not read or write GUMem; its task uses the supplied page list and explicit business inputs. DoAnything opens the supplied pages and produces the scenario output. Supply your own JSON with --input; use --interactive when the user must consent to delegation. For site sign-in and user responses, see Qoni SDK.

Download the complete runnable examples, or run from the documentation repository:

bash
cd examples/qoni
npm ci
npm run case -- event-follow-up-agent
# Supply your own inputs
npm run case -- event-follow-up-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'

// Draft follow-up content using the supplied event segments and notes.
const report = await runScenario('event-follow-up-agent', cliOptions(), inputFile())
// report.items: segments, demo attendee IDs, drafts, and facts for the event owner to review.
// Validated fields: segment, attendeeIds, draft, facts.
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
// Draft follow-up content using the supplied event segments and notes.
browser('event-follow-up-agent',
  'Use the synthetic event and attendee segments with cited public product facts to draft follow-up messages. Return a JSON array [{segment,attendeeIds,draft,facts}] with one item per segment, even when only one segment is supplied. Copy segment names and attendeeIds verbatim from the input; each fact contains claim and sourceUrl. Do not send messages, contact attendees or modify CRM.',
  ['segment','attendeeIds','draft','facts'], undefined, sample([firefox], { eventId:'demo-browser-workshop', segments:[{name:'browser-evaluators',attendeeIds:['demo-attendee-1']}], notes:'Attendees asked how to start using Firefox.' })),

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/event-follow-up-agent/report.json. report.items contains segments, demo attendee IDs, drafts, and facts for the event owner to review, with fields segment, attendeeIds, draft, facts. audit links the grant ID, audit ID, and effective scopes; http.json records redacted request statuses. The application parses DoAnything output and checks required fields. A business reviewer still assesses the content against the original sources.

Data and memory boundaries ​

This scenario touches four kinds of data; only the last optionally belongs in GUMem:

  • Versioned rules: segmentation criteria, brand voice, follow-up rules — managed by version in the follow-up playbook, with every batch of drafts referencing the playbook version.
  • Business state: segmentation results, follow-up drafts, company facts and sources — written back to the CRM as draft records; segments, event history, and contact facts stay canonical in the CRM, with no second source of truth.
  • Audit records: the behavior chain of interactive authorization, every read, and rejected out-of-scope attempts — maintained by GenAuth.
  • User Memory (optional): seller-confirmed long-term tone preferences (for example, "no exclamation marks in follow-ups") — this is where GUMem fits; a one-off event pass neither recalls nor writes back by default.

Failure handling ​

SituationRecommended handling
Event platform or CRM login state expiresSuspend the task, notify the user to sign in again, and resume from the checkpoint.
Public company context is missing or unreliableKeep only the in-list information for that contact; do not fill in speculative company background.
CRM record request outside the delegated scopeReject and record it; the attempted access remains visible in the audit chain.
A draft fact lacks a sourceApp-side validation cuts the fact and the deliverable notes how many were cut.

Production notes ​

Do not send email or rewrite official CRM fields automatically: drafts go back to the CRM as draft records and become official data only after seller confirmation — sending is always a human responsibility. Attendee personal information is limited to publicly visible parts; personal data beyond the list should not enter drafts, CRM draft records, or Memory.

Next steps ​