Skip to content

Newsletter curation agent

This page explains how a newsletter curation agent sweeps public industry sources under a silent grant, deduplicates against the published list in your content database, and creates a curation draft awaiting editor confirmation; the sweep is a long-running task you can reattach to at any time with run.id. After reading it, you will understand when this scenario actually needs interactive consent, how candidates are collected and deduplicated, and why data like published URLs belongs in your content database rather than Memory.

Use case

Marketing teams need to regularly curate newsletters from product updates, blogs, events, industry news, and customer stories. Candidate sources are scattered and update frequently, so manual source-by-source checks are slow and incomplete — and the same piece resurfaces through different syndication channels, requiring source-level deduplication.

Typical triggers:

  • A newsletter issue deadline approaches, and a candidate list and section drafts are needed.
  • A noteworthy industry development appears, and its fit for this issue must be judged quickly.
  • Section structure or selection criteria change, and candidates must be re-filtered against the new configuration.

Engineering challenges

  • The sweep is a long task spanning hours or days: sources are many and update on their own schedules, so one collection run can straddle deployment restarts and cron windows. The task handle must be persistable and reattachable — a process exit cannot mean starting over.
  • Syndication dedupe needs a deterministic basis: the same piece carries different URLs and titles across channels, so deduplication must key on normalized URLs while keeping the original source — otherwise the same content reappears issue after issue.
  • Unsourced content slips into the pool: aggregator and syndication pages often lose the original attribution, and candidates without an accessible link and collection time cannot support editorial decisions — they must be dropped before entering the pool.

Module composition

ModuleRoleNotes
GenAuthCoreSilent delegation issues the short-lived runtime credential every product call requires; interactive consent is only needed when login state or write actions enter the picture. Credentials remain short-lived, revocable, and audited.
Web AgentCoreSweeps public blogs, news, and industry sources with WebSearch, keeping a link and collection time per candidate; long runs persist run.id for reattachment.
GUMemNot usedThe published-URL list and section layout are business state — keep them in your content database, managed per issue. They are not Memory. This scenario has no personal user preference worth persisting across tasks.

Newsletter curation agent architecture

Every product call requires a GenAuth delegate token; public read-only scenarios are covered by silent delegation. The default path here only reads public web pages: your app exchanges the GenAuth user ID bound to your Qoni credentials for a runtime credential, with no user redirect. The credential is explicit, short-lived, and revocable, covering only public reads and task execution; sending, bulk mailing, and subscriber operations sit outside every grant.

Upgrade to interactive consent (mode: 'interactive') when:

  • Candidate collection needs a signed-in content pool, CMS, or paid subscription source.
  • The task performs write actions, such as saving the curation draft into a CMS draft box.

The upgrade works the same as in other scenarios: mode: 'interactive' plus a redirectUri, with the user confirming in Qoni Console and your server exchanging the grant via completeDelegateToken — see the Quickstart for the full flow.

Workflow

Newsletter curation agent workflow

  1. The user selects a newsletter issue, topics, and candidate source range.

  2. Your app obtains a runtime credential through a silent grant (public reads, no user redirect).

  3. Web Agent starts the long-running public source sweep; your app persists run.id and reattaches after process restarts or at the next cron window.

  4. The sweep returns a candidate list, each entry with an accessible link and collection time.

    Checkpoint: Every candidate should carry an accessible source link and collection time; unsourced entries do not enter the candidate pool.

  5. Your app loads the published-URL list and section layout from the content database and injects them into the curation task.

  6. The Agent deduplicates candidates by normalized URL, merges syndicated copies while keeping the original source, and drafts section ideas, summaries, and headlines against the section layout.

  7. The Agent returns the curation draft and source list with an audit id; after the editor confirms, include and exclude decisions are written back to your content database.

    Checkpoint: Drafts stop at "awaiting confirmation"; any action toward sending or bulk mailing should be rejected and recorded.

Example code

The example below wires this scenario into your backend with the official Qoni SDK (@qoniai/qoni): silent delegation → webSearch.run() starts the long sweep and persists run.id → reattach later to collect results → one doAnything.run() deduplicates and drafts against your content database configuration → parse and validate the entries.

ts
import { Qoni, QoniScopes } from '@qoniai/qoni'

const qoni = new Qoni({
  accessKey: process.env.QONI_ACCESS_KEY!,
  secretKey: process.env.QONI_SECRET_KEY!,
})

export async function startCurationSweep(
  userId: string,
  newsletterId: string,
  topics: string[],
) {
  // 1. Silent delegation: public content collection involves no site sign-in
  //    (upgrade to interactive for signed-in pools or write actions)
  const { data: grant } = await qoni.delegateToken({
    user: { id: userId },
    agent: 'newsletter-curation',
    products: ['webSearch'],
    scopes: [QoniScopes.DO_ANYTHING_READ, QoniScopes.DO_ANYTHING_MANAGE],
  })

  // 2. Start the long-running sweep and persist run.id for reattachment
  const sweep = await qoni.webSearch.run({
    token: grant.token,
    prompt: `
      Find recent public posts and industry news about
      ${topics.join(', ')}. Record the link and collection time
      for every candidate.
    `,
    maxResultsPerQuery: 8,
  })
  await saveSweepState(newsletterId, {
    runId: sweep.id,
    token: grant.token,
    auditId: grant.auditId,
    grantedScopes: grant.grantedScopes,
  }) // your task store
  return sweep.id
}

// Later — after a process restart, at a cron window, or on another
// instance — reattach to the same long-running task by run.id
export async function resumeCurationSweep(newsletterId: string) {
  const state = await loadSweepState(newsletterId)
  const sweep = qoni.webSearch.attach(state.runId, { token: state.token })
  const candidates = await sweep.wait()

  // 3. Load the published URLs and section layout from YOUR content
  //    database (business data, not Memory)
  const config = await loadCurationConfig(newsletterId) // e.g. { publishedUrls: [...], sections: [...] }

  // 4. One doAnything run deduplicates, filters, and drafts the sections
  const run = await qoni.doAnything.run({
    token: state.token,
    prompt: `
      Curate the next newsletter issue from these candidates.
      Deduplicate by normalized URL, merging syndicated copies while
      keeping the original source; skip anything already in the
      published list. Draft section ideas, summaries and headlines
      following the section layout. Return entries as a JSON array of
      { section, title, summary, sourceUrl, collectedAt } objects.
      Do not send, bulk mail, or modify any subscriber list — stop at
      a draft for review.

      Public candidates: ${JSON.stringify(candidates.output)}
      Published URLs: ${JSON.stringify(config.publishedUrls)}
      Section layout: ${JSON.stringify(config.sections)}
    `,
    capture: { screenshots: true },
  })
  const result = await run.wait()

  // 5. Parse and validate the output contract on the app side:
  //    entries without a source link or collection time never reach the draft
  const entries = parseCurationDraft(result.output).filter(
    (e) => e.sourceUrl && e.collectedAt,
  )

  return {
    entries,
    artifacts: result.artifacts,
    audit: { auditId: state.auditId, permissionBoundary: state.grantedScopes },
  }
}

The output structure is a contract set by the task prompt: here it is an array of { section, title, summary, sourceUrl, collectedAt }, parsed and validated by parseCurationDraft on the app side, and any entry missing sourceUrl or collectedAt is dropped. Delegation credentials are short-lived: if the credential has expired by the time you reattach (QoniTokenExpiredError), obtain a fresh silent grant first, then attach to the same run.id.

Data and memory boundaries

This scenario touches four kinds of data; GUMem is not used here:

  • Versioned configuration: section layout and selection criteria — managed per issue in your content database or config store, with drafts referencing the configuration version.
  • Business state: candidate lists, published URLs, and the editor's include and exclude decisions — written back to your content database for next-issue dedupe and retrospectives.
  • Audit records: the delegation and behavior chain formed by grantId and auditId — maintained by GenAuth.
  • User Memory (optional): only long-term personal preferences a user has explicitly confirmed belong in GUMem; selection criteria and section layout are team-level configuration, not personal memory, so this scenario neither recalls nor writes back by default.

Failure handling

SituationRecommended handling
Process restart or wait() timeoutReattach with the persisted run.id; the task keeps running server-side, so never start a duplicate.
The credential has expired at reattach timeObtain a fresh silent grant, then attach to the same run.id.
A candidate source is unreachable or the content is taken downRemove it from the candidate pool with the reason recorded; never cite content that cannot be verified.
An entry lacks a source link or collection timeApp-side validation drops the entry and the draft notes how many were dropped.

Production notes

Do not publish or send automatically. External sources should retain links and collection time. A curation draft may enter the sending pipeline only after editor confirmation; cap the sweep frequency against external sources to avoid load on those sites. Keep long-task run.ids and credentials in your server-side task store — never ship them to the browser.

Next steps