Skip to content

Product launch messaging agent ​

This page explains how a product launch messaging agent reads the release pages it is authorized to see in your internal docs system — under an interactive, controlled grant — combines them with public competitor research, and drafts launch messaging that stays consistent across channels. After reading it, you will understand which modules this scenario needs, how unreleased information is constrained by the authorization boundary rather than the prompt, and why positioning and claims rules belong in a policy store rather than Memory.

Use case ​

Product marketing teams need positioning, launch announcements, FAQs, sales snippets, and internal enablement materials for a new product or feature. The release materials — release notes, product docs, internal discussion — live in an internal docs library, and only some pages are relevant to this launch. A launch spans the website, blog, email, social channels, and sales talk tracks; independently written materials drift apart, and information keeps changing as launch day approaches.

Typical triggers:

  • The launch date is set, and a full cross-channel messaging set is due before the launch window.
  • A competitor ships a similar capability around the same time, and the positioning language must be adjusted.
  • Product scope or pricing changes shortly before launch, and every asset must be updated in sync.

Engineering challenges ​

  • Reading unreleased material needs a real boundary: the common shortcut is pasting internal docs into the prompt in plaintext — which hands confidential content to the task description with no control over who read it, how much, or where it went. The right approach is to let the Agent read the pages it is authorized to see inside the docs library, with every read recorded.
  • Cross-channel consistency does not survive manual checks: positioning and claim wording drift between the website, email, and sales talk tracks, and every pre-launch change must be propagated to all assets by hand — one miss is a messaging incident.
  • Internal and public research must stay separated: competitor research goes through the public web, internal reads go through authorized pages; if the two channels mix, unreleased details can leak into public queries.

Module composition ​

ModuleRoleNotes
GenAuthCoreRead-only delegation, revocation, and the audit chain for the authorized pages in the internal docs library; release projects outside the delegated list are unreadable.
Web AgentCoreControlled sessions read the authorized internal release pages and research competitor launches and public industry context with WebSearch, keeping the two channels strictly separate.
GUMemNot usedProduct positioning and claims rules are versioned policy — keep them in your policy store and inject them per version; launch drafts and language decisions are business state and belong in your release archive. Neither is Memory.

Product launch messaging agent architecture

Permission and delegation boundaries ​

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

  • The delegated scope covers only "read the pages of the current release project that are authorized in the internal docs library, and query public market pages" — no reading other product lines' materials, editing product docs, or publishing externally.
  • Delegation credentials are short-lived; minute-level validity is recommended for a single launch task, with re-delegation after expiry.
  • The user or an administrator can revoke the grant at any time; new material-read requests fail immediately after revocation.
  • Out-of-scope attempts (for example, reading another release project's confidential materials) are rejected and recorded — the audit chain covers all attempts, not just successful actions.

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

Product launch messaging agent workflow

  1. The user selects the release project and defines the launch scope, target channels, and the list of authorized release pages.

  2. GenAuth starts an interactive delegation; after the user confirms in Qoni Console, your server-side callback exchanges it for a least-privilege credential.

  3. Your app loads the current version of positioning language and claims rules from the policy store and injects them into the task.

  4. Web Agent opens the authorized release pages in a controlled session and reads the release notes, docs, and discussion highlights.

    Checkpoint: Unreleased information is read and used only within the authorization boundary; subsequent public web queries must not contain any unreleased detail.

  5. Web Agent researches competitor launches and public industry context through WebSearch, keeping a source URL for every external fact.

  6. The Agent generates cross-channel messaging drafts — positioning language, launch announcement, FAQs, sales snippets, and enablement materials — and checks consistency channel by channel.

  7. The Agent returns the full draft set, a source list, and flagged discrepancies with an audit id attached; after app-side validation they go to product and legal for confirmation.

    Checkpoint: Product claims across channel drafts should agree with each other and follow the claims policy version; statements that conflict with established positioning should be flagged for confirmation, never adopted silently.

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 drafts newsletter and website copy for a synthetic demo-release launch. 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 -- product-launch-messaging-agent
# Supply your own inputs
npm run case -- product-launch-messaging-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 launch copy from approved claims and the official product page.
const report = await runScenario('product-launch-messaging-agent', cliOptions(), inputFile())
// report.items: channels, drafts, cited claim IDs, and source URL lists.
// Validated fields: channel, draft, claimIds, sourceUrls.
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 launch copy from approved claims and the official product page.
browser('product-launch-messaging-agent',
  'Create a synthetic launch announcement draft for each supplied channel using only current supported features from the cited product page. Return [{channel,draft,claimIds,sourceUrls}]. The demo campaign is fictional; do not invent a real launch date, unreleased feature, or publish anything.',
  ['channel','draft','claimIds','sourceUrls'], 'sourceUrls', sample([firefox], { releaseId:'demo-release', channels:['newsletter','website'], approvedClaimIds:['official-features'] })),

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/product-launch-messaging-agent/report.json. report.items contains channels, drafts, cited claim IDs, and source URL lists, with fields channel, draft, claimIds, sourceUrls. 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; GUMem is not used here:

  • Versioned rules: positioning language and claims rules — managed by version in your policy store, referenced by version number in every draft set.
  • Business state: release materials, cross-channel drafts, language decisions — kept in your internal docs library and release archive; unreleased information never leaves the authorization boundary.
  • Audit records: the delegation and behavior chain formed by grantId and auditId, including every internal page read — maintained by GenAuth.
  • User Memory (optional): only long-term personal preferences a user has explicitly confirmed belong in GUMem; launch language is team-level policy, not personal memory, so this scenario neither recalls nor writes back by default.

Failure handling ​

SituationRecommended handling
Access to an authorized release page is deniedTreat it as a boundary rejection with a record, prompt the user to confirm the delegated scope, and never fall back to guessing.
No reliable source for competitor or industry informationMark it as unconfirmed; never write unsourced conclusions into external materials.
Material request outside the delegated scopeReject and record it; the attempted access remains visible in the audit chain.
A draft lacks a channel, claim annotations, or a source listApp-side validation drops the draft and records how many were dropped and the policy version applied.

Production notes ​

Unreleased information must not enter public web tasks — and must not be pasted into task descriptions in plaintext either; controlled reads of authorized pages are the point of this scenario. External messaging needs product, legal, or owner confirmation before release. Information changes frequently before launch day, so deliverables should carry a generation timestamp, the release pages they are based on, and the policy version, preventing stale drafts from being mistaken for the final language.

Next steps ​