Customer onboarding agent
This page explains how a customer onboarding agent observes the customer environment's configuration state under a read-only grant, combines it with customer goals and communication history to generate a personalized onboarding checklist, and hands write actions back to the customer. After reading it, you will understand why all three modules are core here, why environment state must come from the source system rather than memory, and how the takeover handback works.
Use case
Customer success teams want an Agent that generates an onboarding plan and next-step checklist from the customer's industry, purchased products, and communication history, then guides the new customer through configuration based on the actual state of their environment (authorized pages). Checking each customer's environment manually is expensive; giving a script an account that can read and write the customer environment means any slip lands on the customer's production configuration.
Typical triggers:
- A new customer signs, and a personalized onboarding plan is needed for their industry and purchased products.
- Configuration stalls at some step, and the environment state must be read to locate the blocker and produce the next move.
- Before trial-to-paid conversion, all required configuration items must be verified as complete.
Engineering challenges
- Environment state drifts constantly: the checklist depends on the customer environment's actual configuration, which the customer can change at any moment. Inferring current state from the last conversation or a previous checklist is guaranteed to go wrong — every run must observe the source system fresh.
- The read/write responsibility boundary: verifying configuration only needs reads, but "guiding the customer to enable a feature" naturally tempts the Agent to do the write itself. Once it writes into the customer's production environment, any mistake is a vendor-side incident with no clean way to split responsibility.
- Context lives in two kinds of systems: customer goals and communication history are cross-session customer memory; configuration reality is the environment's current fact. Confusing the two — treating memory as environment truth — produces checklists that walk the customer through steps already done or no longer valid.
Module composition
| Module | Role | Notes |
|---|---|---|
| GenAuth | Core | Interactive read-only delegation, revocation, and the audit chain; writes are excluded from the grant. |
| Web Agent | Core | Controlled sessions read authorized environment state pages with per-item evidence; write steps issue a takeover link handed back to the customer. |
| GUMem | Core | Customer goals, communication highlights, and confirmed preferences — the customer context that spans sessions; environment configuration state is not part of it and is observed fresh each run. |
Permission and delegation boundaries
The Agent holds no inherent permissions. The effective authority for each task is the intersection of three sets: what the customer success user actually holds ∩ what was explicitly delegated for this task ∩ what the enterprise has approved. Applied here:
- The delegated scope covers only "read this customer's records, communication history, and authorized environment state pages" — no changing customer environment configuration, signing commitments, or altering contract terms.
- Delegation credentials are short-lived; minute-level validity is recommended for a single onboarding check, with re-delegation after expiry.
- The customer success lead or an administrator can revoke the grant at any time; new environment reads fail immediately after revocation.
- Out-of-scope attempts (for example, submitting a configuration change form) 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 customer environment's 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
The customer success user opens the customer workspace and triggers the onboarding task.
GenAuth runs interactive delegation for the lead's explicit consent and issues a read-only credential.
GUMem recalls customer goals, communication highlights, and confirmed preferences — no configuration state.
Web Agent reads the authorized environment state pages and verifies completed and missing configuration items one by one.
Checkpoint: Every environment-state judgment comes from this run's observation and maps to concrete page evidence; unreadable items are marked "unknown" — never filled in from memory or a previous report.
The Agent generates the personalized onboarding checklist: completed items, missing items, recommended order, and matching doc links.
For steps that require a write (changing configuration, enabling a feature), the Agent raises a takeover interaction; your app forwards the takeover link to the end customer, who performs the action in their own session.
Checkpoint: The Agent never modifies production configuration for the customer; after the customer hands the session back, Web Agent re-observes the environment state before updating the checklist.
The customer success user reviews the checklist and progress, then shares it with the customer; progress milestones and confirmed communication highlights 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 customer wants to evaluate Firefox on desktop, using the official getting-started page and a short-step 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:
cd examples/qoni
npm ci
npm run case -- customer-onboarding-agent
# Supply your own inputs
npm run case -- customer-onboarding-agent --input /path/to/input.jsonSet 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:
import { cliOptions } from '../runtime.js'
import { inputFile, runScenario } from '../run-case.js'
// Draft an onboarding checklist using the customer goal and confirmed preferences.
const report = await runScenario('customer-onboarding-agent', cliOptions(), inputFile())
// report.items: steps, status, evidence, and sources; proposed steps do not mean completion.
// Validated fields: step, status, evidence, sourceUrl.
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.
// Draft an onboarding checklist using the customer goal and confirmed preferences.
browser('customer-onboarding-agent',
'Use the supplied synthetic customer goal and public getting-started guide to produce an onboarding checklist. Return [{step,status,evidence,sourceUrl}]. Proposed steps are suggestions; do not claim a customer completed them. Do not change a customer account, CRM or official onboarding status.',
['step','status','evidence','sourceUrl'], 'sourceUrl', sample([support], { customerId:'demo-customer-1',goal:'Evaluate Firefox on a desktop',confirmedPreferences:['Use short steps.'] }), 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
// 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,
})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/customer-onboarding-agent/report.json. report.items contains steps, status, evidence, and sources; proposed steps do not mean completion, with fields step, status, evidence, sourceUrl. 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: customer goals, communication highlights, confirmed preferences, and onboarding progress milestones, all timestamped — the customer context that spans sessions.
- Not into Memory: the customer environment's configuration state. The customer can change it at any moment, and GUMem is not the source of configuration truth — "done/missing" judgments come only from Web Agent's observation in this run, never from memory or a previous report.
- Corrections: when customer goals or commitments change, mark the old record invalidated and point it to the new memory instead of physically deleting it, keeping past guidance decisions traceable.
Failure handling
| Situation | Recommended handling |
|---|---|
| Customer environment sign-in state expires | Suspend the task, notify the customer or the lead to sign in again, and resume from the checkpoint. |
| An environment page is unreadable or its structure changed | Mark the item "unknown" and replay the session recording; never emit state without evidence. |
| A configuration write request outside the grant | Reject and record it; the attempted action remains visible in the audit chain. |
| A recalled commitment conflicts with this conversation | The lead's confirmation in this task wins; write the correction back to GUMem. |
Production notes
Environment state must come from the source system: every "done/missing" judgment in the checklist derives only from this run's page evidence, never from GUMem or historical reports. Customer commitments, contracts, and SLAs must not be generated or modified by the Agent — they require the lead's confirmation. No write to the customer's production environment belongs in the Agent's grant: changes go through a takeover link handed back to the end customer, every takeover and handback leaves an audit record, and after handback the Agent re-observes before updating the checklist.
Next steps
- Read Authorization and browser sandbox for the security boundaries of controlled sessions and human takeover.
- Read the Quickstart to run the shortest path for Agent identity and delegation.
- Continue with the Support knowledge agent for an adjacent scenario.