Skip to content

Partner portal monitoring agent ​

This page explains how a partner portal monitoring agent signs in to a partner portal under the owner's interactive delegation, uses Track to keep a long-lived watch on policy, price list, rebate, and announcement pages, and notifies the owner with evidence when a change hits. After reading it, you will understand why signed-in monitoring must use interactive delegation, how Profiles lets later rounds reuse the login state, and why policy baselines belong in a monitoring store rather than Memory.

Use case ​

Channel, brand, or marketing teams need to monitor product titles, images, pricing, inventory, promotions, and descriptions in partner portals, along with policy documents, price lists, rebate rules, and announcements that are only visible after sign-in. These pages have no public API, and signing in to each portal manually is slow and misses updates.

Typical triggers:

  • A partner portal publishes a new rebate rule or price list, and the channel owner must be notified before it takes effect.
  • Before a promotion window opens, product presentation across several portals must be checked against brand rules.
  • Before renewing a channel agreement, the team must confirm how portal policy documents changed since the last cycle.

Engineering challenges ​

  • Login-state maintenance: portal content sits behind a login wall, and session expiry, MFA, and risk-control pages are the norm. Unattended monitoring stands or falls on whether the login state can be reused safely and escalates to a human on failure instead of dying silently.
  • Change grading: portals have routine updates every day; what actually needs escalation is substantive change — "the rebate rule changed," "the policy start date moved up." Without a versioned policy baseline, grading falls back to a human re-reading everything.
  • Portal heterogeneity: every portal differs in page structure, terminology, and update style, so baseline conventions are hard to unify; one structural redesign can turn the whole monitoring chain into noise.

Module composition ​

ModuleRoleNotes
GenAuthCorePortal login state is high-risk delegation: interactive authorization, short-lived credentials, revocation, and audit trails for out-of-scope attempts are all carried by GenAuth.
Web AgentCoreProfiles stores and reuses the authorized login state; Track maintains the long-lived monitor that fetches, compares, and calls back on schedule.
GUMemOptionalOnly stores owner-confirmed long-term risk preferences (for example, "always escalate rebate changes"). Policy and price baselines are business state — they go to the baseline store, managed by version, not to Memory.

Partner portal monitoring agent architecture

Permission and delegation boundaries ​

The Agent holds no inherent permissions. The effective authority for each monitoring 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 policy, pricing, rebate, announcement, and product display pages on the specified partner portals" — no ordering, price changes, listing edits, or contacting partners.
  • Portal sign-in is a high-risk operation and must use mode: 'interactive': the owner approves in Qoni Console, and the credential is exchanged in a server-side callback — it never reaches the browser.
  • Delegation credentials are short-lived; long-running monitoring relies on scheduled re-issuance, not one long-lived credential.
  • Out-of-scope attempts (for example, an order management page outside the delegation) are rejected and recorded — the audit chain covers all attempts, not just successful actions.

Note: this sample requests track product permissions. Your application and downstream services must configure and enforce business limits such as portal domains, page ranges, action whitelists. Product scopes and prompt rules do not enforce those detailed limits; this sample does not configure them. See Delegate token and attenuation.

Workflow ​

Partner portal monitoring agent workflow

  1. The owner selects partners, portals, page scope, and the notification channel.

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

  3. Your app creates the Track monitor; the first run opens the portal, the owner completes sign-in in the controlled session, and Profiles stores the login state for later ticks.

    Checkpoint: When a login wall, MFA, or risk-control page appears, escalate to a human instead of silently bypassing it.

  4. The first run baselines the policy, price list, rebate, and announcement pages; the baseline goes to the baseline store together with its version.

  5. Every scheduled run afterwards fetches the current pages and compares against the baseline; whether something changed is decided by rules, not by model wording.

  6. On a hit, Track calls back to your app with a change record: changed fields, screenshot evidence, and source URLs.

  7. Your app grades the change against the policy baseline (optionally combined with owner-confirmed risk preferences) and pushes the anomaly, evidence, suggested actions, and audit id to the owner.

    Checkpoint: Every anomaly should trace back to a concrete page screenshot and source URL; change verdicts without evidence should not enter the notification.

Example code ​

Legacy Track sample compatibility

This case retains the published npm @qoniai/qoni@0.9.0 sample code. Its legacy /track/monitors route returns 404 against the current /track/tracks backend, so this case will not pass there. Callbacks, snapshots, and profile_id describe the old monitor contract and are not current Track capabilities. For the current HTTP contract and unpublished Unreleased 0.10.0 SDK candidate, see Track.

Check extraction results

Record success only when the monitor state and extracted data match the expected output. If the state is completed but extracted is empty, the sample fails. Check the target page, extraction fields, and execution state.

This example uses @qoniai/qoni 0.9.0, published on npm. The download includes the same SDK version, installed with npm ci. The demo monitors the public Firefox privacy page for an initial extraction, without a private partner account. The SDK reads real public pages; business inputs in scenarios.ts are labeled public-demo.

The sample creates a Track monitor, runs one extraction, checks the result and pause/resume state, then deletes the monitor. This sample does not cover long-running scheduling, change notifications, or private-portal sign-in persistence. 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 -- partner-portal-monitoring-agent
# Supply your own inputs
npm run case -- partner-portal-monitoring-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'

// Create a Track monitor and inspect its first extraction.
const report = await runScenario('partner-portal-monitoring-agent', cliOptions(), inputFile())
// report.extracted: monitor and run IDs, headings, and sources; empty extraction fails.
// Validated fields: heading, source_url.
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
// Create a Track monitor and inspect its first extraction.
{ id:'partner-portal-monitoring-agent', products:['track'], task:'Monitor the supplied pages for substantive policy changes. Extract heading and source_url. This demonstration uses a public policy page; authenticated partner portals require approved login and persistent profiles.', fields:[], input:sample([privacy], { watchedSections:['privacy policy'] }) },

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
}

After a successful tick, output/partner-portal-monitoring-agent/report.json contains the monitor ID, tick run ID, actual extraction, and effective scopes. http.json records redacted request statuses. A completed state with empty extraction fails validation. If your deployment returns empty data, check the Track scraper, extractor, and worker configuration before retrying; this example cannot certify that deployment as working.

Data and memory boundaries ​

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

  • Versioned rules: policy baselines, monitored-page lists, grading and escalation rules — managed by version in the baseline store, with every notification referencing the baseline version.
  • Business state: page snapshots, change records, screenshot evidence — carried by Track's run records and your monitoring store, replayable.
  • Audit records: the behavior chain of interactive authorization, every fetch, and rejected out-of-scope attempts — maintained by GenAuth.
  • User Memory (optional): owner-confirmed long-term risk preferences (for example, "always escalate rebate changes") — this is where GUMem fits; policy and price baselines never enter Memory.

Failure handling ​

SituationRecommended handling
Login state expiresThe monitor signals that a human is needed (intervene); the owner signs in again and the watch continues — the key to closing the loop in unattended monitoring.
A portal redesign breaks the comparisonTreat it as a failure and replay that run; rebuild the baseline after human confirmation, and never emit change conclusions without evidence.
Request outside the delegated scopeReject and record it; the attempted access remains visible in the audit chain.
A callback entry lacks a screenshot or sourceApp-side validation drops the entry and records how many were dropped; no evidence, no notification.

Production notes ​

The Agent should not place orders, change prices, edit listings, or contact partners. Treat the output as a review report; the owner decides follow-up actions. Monitoring is read-only and notify-only — it never performs changes on a human's behalf. Set a floor on the schedule interval based on how often the portal actually updates, to avoid load on target sites; after the grant is revoked, the monitor's next fetch fails immediately — that is expected behavior, not a fault.

Next steps ​

  • Read Track for the monitor's full lifecycle and event stream.
  • Read the Quickstart to run the shortest path for Agent identity and delegation.
  • Continue with the Vendor monitor agent for the adjacent public-page monitoring scenario.