Skip to content

Support knowledge agent ​

This page explains how a support knowledge agent queries public documentation and the authorized ticket system under a customer-scoped grant, and combines that with this customer's historical context to draft sourced answers. After reading it, you will understand why all three modules are core here, why canonical answers live in the knowledge base rather than Memory, and where the customer-data trimming boundary sits.

Use case ​

Support teams need an Agent that answers product questions, troubleshoots common failures, or produces better-fitting suggestions from a customer's history. Answer material is scattered across product docs, status pages, community posts, and the ticket system — digging through them manually slows response times, and handing a support account to a script means the reachable customer data in the ticket system is completely unbounded. The Agent's output is an answer draft for the support team to review; it never replies to customers directly.

Typical triggers:

  • High-frequency questions surge (for example, after a release), and sourced standard-answer drafts are needed fast.
  • A hard ticket needs similar historical tickets, docs, and community threads consolidated before replying.
  • A product announcement or known-issue update lands, and existing answers must be checked for staleness.

Engineering challenges ​

  • Answer material mixes fresh and stale: product docs, status pages, and community posts update on different rhythms, and one announcement instantly expires old answers scattered everywhere. Without a source link and timestamp per conclusion, support cannot judge what is still trustworthy.
  • Two kinds of knowledge are easy to conflate: standard answers and product knowledge are canonical, team-maintained, versioned knowledge-base content; this customer's environment facts and handling history are customer context. Copying knowledge-base content into customer memory turns it into stale private copies the moment the product changes — with no central way to fix them.
  • The customer-data trimming boundary: a support account can see far more tickets than this question needs. Context passed to the web task must be trimmed to what this question requires; unrelated customer data cannot be recalled once it enters the task.

Module composition ​

ModuleRoleNotes
GenAuthCoreInteractive delegation scoped to the current customer context; revocation and an audit chain covering every access attempt.
Web AgentCoreQueries public docs, status pages, and community posts (WebSearch locates sources) and retrieves similar tickets within the authorized scope.
GUMemCoreCarries only this customer's historical issues, handling notes, and confirmed environment facts; standard answers and product knowledge belong to the knowledge base, not Memory.

Support knowledge agent architecture

Permission and delegation boundaries ​

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

  • The delegated scope covers only "read tickets, history, and public knowledge sources related to the current customer" — no replying to customers, closing tickets, or modifying customer data.
  • Delegation credentials are short-lived; minute-level validity is recommended for a single answering task, with re-delegation after expiry.
  • The support user or an administrator can revoke the grant at any time; new ticket reads fail immediately after revocation.
  • Out-of-scope attempts (for example, reading another customer's tickets unrelated to this question) are rejected and recorded — the audit chain covers all attempts, not just successful actions.

Note: this sample requests doAnything product permissions and GUMem read/write scopes. Your application and downstream services must configure and enforce business limits such as the ticket system's domain lists and customer ranges. Product scopes and prompt rules do not enforce those detailed limits; this sample does not configure them. See Delegate token and attenuation.

Workflow ​

Support knowledge agent workflow

  1. The support user opens the customer conversation and triggers the answering task.

  2. GenAuth runs interactive delegation for the support user's explicit consent and issues a credential scoped to this customer's context.

  3. GUMem recalls this customer's historical issues, handling notes, and confirmed environment facts — knowledge-base content is not recalled.

  4. Web Agent queries the latest product docs, status pages, and community threads, and retrieves similar tickets within the authorized scope.

    Checkpoint: Context passed to the Web Agent must be trimmed to what this question needs — no unrelated customer data travels with the task.

  5. The Agent consolidates the sources into an answer draft, attaching a source link and timestamp to every key conclusion.

  6. The support user reviews and edits the draft, then replies to the customer; the Agent never contacts the customer directly.

    Checkpoint: Every conclusion in the draft traces back to a concrete source; when a cited doc conflicts with a product announcement, it is flagged "to verify" rather than silently resolved.

  7. Reviewed standard answers are updated in the knowledge base (maintained by the team); this customer's environment facts and handling notes are written back to GUMem.

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 answers a synthetic ticket about getting started with Firefox on desktop using official documentation and a concise-reply preference. The SDK reads real public pages; business inputs in scenarios.ts are labeled public-demo.

The sample writes and recalls confirmed preferences under an isolated demo user, then includes that context in the browser task. 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 -- support-knowledge-agent
# Supply your own inputs
npm run case -- support-knowledge-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'

// Recall confirmed preferences and draft a support answer from official docs.
const report = await runScenario('support-knowledge-agent', cliOptions(), inputFile())
// report.items: conclusions, source references, and collection timestamps for support review.
// Validated fields: conclusion, sourceRef, 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
// Recall confirmed preferences and draft a support answer from official docs.
browser('support-knowledge-agent',
  'Answer the synthetic support question with sourced public documentation. Return [{conclusion,sourceRef,capturedAt}]. Identify uncertainty rather than inventing instructions. Draft only: do not reply to a customer, close a ticket or change any account.',
  ['conclusion','sourceRef','capturedAt'], 'sourceRef', sample([support], { ticketId:'demo-ticket-1',question:'How can I start using Firefox on a desktop?',confirmedPreferences:['Keep replies concise.'] }), true),

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/support-knowledge-agent/report.json. report.items contains conclusions, source references, and collection timestamps for support review, with fields conclusion, sourceRef, 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.

Memory strategy ​

  • Into Memory: this customer's historical issues, handling notes, and confirmed environment facts (with sources and timestamps) — context that belongs to this customer alone.
  • Not into Memory: standard answers, product knowledge, and troubleshooting guides. They are canonical knowledge-base content, maintained by the team per version and injected at task time; copying them into Memory creates stale private copies that cannot be centrally corrected after a product update.
  • Corrections: when an environment change invalidates an old fact (for example, the customer upgraded), mark the old fact invalidated and point it to the new memory instead of physically deleting it, keeping past replies traceable.

Failure handling ​

SituationRecommended handling
Ticket-system sign-in state expiresSuspend the task, notify the support user to sign in again, and resume from the checkpoint.
Sources contradict each otherPresent the disagreement as-is with confidence labels; the support user decides — never guess.
A customer-data request outside the grantReject and record it; the attempted access remains visible in the audit chain.
A recalled customer fact conflicts with the current ticketThe fact confirmed in this ticket wins; mark the old fact invalidated and write it back to GUMem.

Production notes ​

Standard answers and product knowledge are canonical in the knowledge base: reviewed answers are updated there for the whole team, not written into an individual customer's Memory. Never pass customer data to web tasks that do not need it — Web Agent should only receive trimmed task context. The Agent's output is always a draft for the support team; it must not be configured to reply to customers directly, and final responsibility for outbound replies stays with the reviewing support user.

Next steps ​