Quickstart: sales follow-up assistant
This page follows one enterprise sales scenario to show how an enterprise app calls the Qoni SDK in order. The rep delegates access to the agent, and the agent checks customers and email every day through Web Agent and drafts follow-ups. At key moments, signing in to Airtable and Gmail, two-step verification and sending, the agent hands the decision back to the rep. DoAnything records the email history and the commitments on both sides in GUMem automatically.
Toward the agentic web
Qoni's goal is to make agents first-class citizens of the web. Models think, the web is where agents act, and Qoni is the layer in between. The diagram shows the layers: a person delegates scoped, time-bound authority to the agent, and every step is audited; the agent works on web pages for the person in hosted cloud browsers, reusing sign-ins across tasks; when it is done, it hands the results back to the person.
Qoni is managed infrastructure for personal agents. It gives an agent the three things it needs — identity, action and memory: GenAuth gives the agent its own identity, bound to a person, with grants that are scoped, time-bound and audited at every step; Web Agent lets the agent work on real websites in hosted cloud browsers, no API needed; GUMem remembers what the user said and did, so nothing has to be taught twice. All three come through one Qoni SDK.
Scenario
Alice, a sales rep, tells the team's follow-up assistant:
Connect my Airtable and my mailbox. Every day, check the customer list against our email threads, find the customers who need a follow-up but haven't had one yet, and never send the same follow-up twice. Check with me before sending; once it's sent, update the follow-up status and the last-contacted time in Airtable, then move on to the next customer.
Remember each customer's needs, preferences, what both sides committed to and the next steps across sessions. When I ask "where did we leave off with this customer?", recall the relevant memory, show the source emails, and keep the follow-up going.
To turn this request into a follow-up routine that runs every day, the agent needs three things:
| What it needs | Why | Step |
|---|---|---|
| The rep's own delegation | The agent acts in Alice's name; the delegation limits what it may do and for how long | 1 |
| A run that works with the rep | Opens Airtable and Gmail with Alice's own accounts to check, draft, send and write back; asks Alice at sign-in, two-step verification and before each email | 2 |
| Customer memory across sessions | Remembers needs, preferences, commitments and next steps, avoids duplicate follow-ups and points back to the source emails | 2 3 |
How it works
The diagram shows the overall logic. The top half is the delegation, obtained silently for each rep every day. The bottom half is the daily follow-up run.
Delegation never interrupts the rep: every business morning your app silently gets the day's delegation token in the rep's own name, with no consent page. The run then works in Airtable and Gmail in a controlled browser with the rep's own accounts; the rep only confirms before each email goes out. DoAnything writes the emails it reads and the commitments it sends into GUMem automatically, and the next check and "where did we leave off?" both use them.
Before you start
- Node.js 20.11 or later.
- A Qoni AccessKey ID and secret whose permission policy allows every scope requested on this page.
- Each rep has an account in the GenAuth user pool bound to this AccessKey, and your app knows their user ID. Silent delegation uses it to know which rep the agent works for.
- Each rep has their own Airtable account with access to the team's customer base, and their own Gmail. On the first run, the agent asks the rep to sign in in a live browser.
- An Airtable customer table with the fields below:
| Field | Purpose |
|---|---|
Owner | The email of the rep who owns this customer. The agent only handles the rep's own customers |
Contact email | The customer contact's email, used to find the threads in Gmail |
Stage | The sales stage, for example Proposal or Negotiation |
Follow-up status, Last contacted | The follow-up status and the last contact time. After sending, the agent sets them to Followed up and today |
- A way to put requests in front of the rep, such as a web dialog or a mobile push. Step 2 uses it to show the live browser and the confirmation before each email.
Install the SDK and initialize the client on your app server:
npm install @qoniai/qoniimport { Qoni, type InteractionHandle } from '@qoniai/qoni'
// Initialize on your app server only; the AccessKey must never reach the browser
const qoni = new Qoni({
accessKey: process.env.QONI_ACCESS_KEY!,
secretKey: process.env.QONI_SECRET_KEY!,
})Integration guide
1. Delegate to the agent
This step gets the agent the day's delegation token in Alice's name. An enterprise app is a trusted server, so it uses silent delegation, with no consent page and no interruption for Alice. Your scheduler runs it for each rep every business morning.
// Every scope this scenario uses
const scopes = [
'webagent.do_anything:manage', // Start follow-up runs and answer interactions
'webagent.site_login:request', // Open the Airtable and Gmail sign-in pages in a controlled browser
'webagent.site_login:confirm', // Save the rep's sign-in so later runs skip it
'gumem.memory:read', // Let DoAnything recall customer memory
'gumem.memory:write', // Let DoAnything write the email history and commitments
]
// Silent delegation: get today's token in the rep's own name; interactions wait for the rep, so allow a full workday
const grant = await qoni.genauth.delegateAgent({
mode: 'silent',
userId: '<rep-user-id>', // the rep's GenAuth user ID
agent: 'sales-follow-up', // the agent's audit label, used in audit records
scopes,
expiresIn: '8h',
})
console.log(grant.grantId, grant.auditId)- Airtable and Gmail have no Qoni scopes: the agent works in them in a controlled browser with Alice's own accounts, so it can see and change exactly what Alice can.
webagent.site_login:*lets the agent ask Alice to sign in in a controlled browser and save the sign-in;gumem.memory:*lets DoAnything read and write the customer memory under Alice's name. For every scope you can request, see Qoni SDK: Requestable scopes.- Silent delegation skips the consent page: the AccessKey's permission policy caps which scopes the agent can request, and
userIddecides which rep it works for. Only get a token for the rep you are about to work for; never delegate with someone else's user ID.
Check: your server records grant.grantId and grant.auditId for later audit.
2. Every day: start the follow-up run with DoAnything and handle interactions
This step hands Alice's request to Web Agent. DoAnything opens Airtable and Gmail with Alice's accounts in a controlled browser, checks customers one by one and drafts follow-ups. When it needs Alice, it pauses and raises an interaction, then waits for your app to submit her decision. This scenario raises four interactions in order: sign-in and two-step verification only on the first run, and a confirmation before each email:
- AgentOpens the Airtable customer table in a controlled browser
- Sign in to AirtableThe rep signs in with their own account in the live browser. Neither the agent nor your app sees the password.Your app callshandle.openLogin()handle.confirmSignedIn()
- AgentReads the customers Alice owns, then opens Gmail
- Sign in to GmailAlso signed in by the rep. DoAnything reuses the saved sign-in on later runs while it stays valid.Your app callshandle.openLogin()handle.confirmSignedIn()
- AgentGoogle sees a new device and asks to verify the sign-in
- Two-step verificationThe rep confirms on their own phone or types the code, then hands the browser back to the agent.Your app callshandle.connectControl()handle.releaseControl()
- AgentChecks each customer against the Gmail threads and recalled memory, skips anyone contacted today, and drafts a reply for Northwind
- Confirm before sendingThe summary names the customer, the reason and the draft. The agent clicks “Send” only after the rep confirms.Your app callshandle.confirm()handle.reject()
- AgentSends in the original thread, updates Airtable, records the email in GUMem, and moves on to the next customer
With step 1's token for the day, start the follow-up run:
const run = await qoni.doAnything.run({
token: grant.token,
memory: { namespace: 'sales-follow-up', citeSources: true }, // Emails and commitments go into GUMem automatically and are recalled when needed
prompt: `
Open the Airtable customer table https://airtable.com/appAcmeSales and look only at customers whose Owner is me (alice@acme.com).
Check each customer's threads in Gmail. A follow-up is due when any of these holds:
the customer's last email has had no reply for more than 2 business days; a quote or document we promised is overdue;
a customer in Proposal has had no email for 14 days.
Skip customers whose Last contacted is today, or whose latest email in the thread is ours from the last 2 business days, so nothing is sent twice.
For each customer due, draft a reply in the original thread that cites only facts from the emails, and ask me before sending.
After I approve, send it in the original thread, set Follow-up status to Followed up and Last contacted to today in Airtable, then move to the next customer.
Return only JSON: { followedUp: [{ company, subject, threadUrl }], skipped: [{ company, reason }] }.
`,
})Then handle interactions by type in run.wait(). Interactions come in five types based on what the rep has to do, independent of any particular business: sign-in (site_login), human takeover (take_control), ask the user (ask_user), confirmation (confirmation) and fill in details (fill_form); see Personal Agent: interaction types for what each type means and its methods. The handler has the same skeleton as Personal Agent's, with comments that use this scenario as the example: In this scenario shows what this run's payload looks like and how often it occurs, and → marks what your app does. Both the daily follow-up and step 3's "where did we leave off" use it.
const handled = new Set<string>() // IDs of interactions already handled
async function handleInteraction(handle: InteractionHandle) {
const request = handle.interaction
// One interaction triggers the callback on creation, on status updates and on event replay:
// handle only pending ones, once per ID
if (request.status !== 'pending' || handled.has(request.id)) return
handled.add(request.id)
// Branches follow the order they occur in this scenario
switch (request.type) {
// Sign-in: the rep signs in to the sites in request.payload.sites themselves
// In this scenario: twice on the first run, once per site; once the sign-in is saved, later runs skip it.
// sites is first [{ siteId: 'airtable', displayName: 'Airtable', loginUrl: 'https://airtable.com/login' }],
// then [{ siteId: 'gmail', displayName: 'Gmail', loginUrl: 'https://accounts.google.com' }]
case 'site_login': {
const login = await handle.openLogin() // open the controlled sign-in browser; returns the live view URL login.liveUrl
// → push login.liveUrl to Alice; she signs in there with her own password, then taps "I'm signed in"
await handle.confirmSignedIn() // call after "I'm signed in": DoAnything saves the sign-in, re-checks and goes on
break
}
// Human takeover: the rep's own hands are needed on the browser; request.payload.reason says why
// In this scenario: once each time Google asks for two-step verification,
// usually on a new device or after a long time
case 'take_control': {
await handle.connectControl() // take over the browser
// → push request.payload.liveUrl to Alice; she completes two-step verification on the live view
// await handle.refreshControl() // refresh when the live view expires
await handle.releaseControl() // call after Alice taps "Done": hand the browser back to the agent
break
}
// Confirmation: request.payload.summary describes what the rep is asked to allow; allow or deny only
// In this scenario: once before each follow-up email, so five customers today means five in a row.
// summary names the customer, the reason and the draft, for example "Northwind Labs ·
// the quote promised for Oct 3 is overdue · Draft: Hi Dana, following up on the quote…"
case 'confirmation': {
// → your app shows the customer and draft from the summary, with "Send" and "Don't send" buttons
await handle.confirm() // Alice allows it: the agent sends in the thread, updates Airtable, moves on
// await handle.reject() // Alice denies it: this email is not sent and the agent moves on
break
}
// Ask the user: request.payload.question is the question; answerType and options describe the answer
// In this scenario: once per customer the agent is unsure about, for example a company with two contacts:
// question is "Which Northwind Labs contact should I follow up with?", answerType is 'single_choice',
// options is [{ value: 'dana@northwind.io', label: 'Dana Lee (procurement)' },
// { value: 'sam@northwind.io', label: 'Sam Park (tech lead)' }]
case 'ask_user': {
// → your app draws a single-choice control; Alice picks Dana and you submit that option's value
await handle.answer('dana@northwind.io')
// await handle.skip() // Alice does not answer: the agent skips this customer until tomorrow
break
}
// Fill in details: render a form from request.payload.fields and submit values keyed by field.name
// In this scenario it never happens. If your task needs the rep to supply data, such as a quote amount,
// the agent raises it with fields like [{ name: 'quoteAmount', label: 'Quote amount', type: 'number', required: true }]
case 'fill_form': {
// → your app renders a form from fields and submits once the rep fills it in
await handle.submit({ quoteAmount: 18000 })
// await handle.skip() // the rep does not fill it in: the agent skips the steps that need it
break
}
}
}const result = await run.wait({ onInteraction: handleInteraction })
console.log(result.status, result.output)The same type can occur more than once:
- Every interaction has its own
request.idandpayload. In this scenarioconfirmationrepeats the most: once per follow-up email, so as many times as there are customers to follow up today. On the first runsite_loginalso appears twice, once for Airtable and once for Gmail. - Interactions come one at a time: only after Alice handles the confirmation for one email does the agent send it, update Airtable and draft the next. Your UI never receives two emails waiting for confirmation at once.
- One interaction also triggers the callback more than once: its creation, its status moving from
pendingtoactiveandresolved, and event replay after a reconnect all callonInteractionagain. That is why the handler only handles thependingstatus and remembers each handledrequest.id, so the same email is never pushed to Alice twice or sent twice. Your UI should likewise update the same card byrequest.idinstead of creating a new one per callback.
A few notes:
- Sign-ins are reused automatically: once Alice taps "I'm signed in", DoAnything saves the sign-in itself, and on every later run it decides on its own whether it can be reused and goes straight in while it is valid; it raises
site_loginagain only when the sign-in has expired. So sign-in and two-step verification usually appear only on the first run, leaving just the confirmation before each send. - No duplicate sends: the task description spells out two skip rules, and the agent checks them in the browser against Airtable's
Last contactedand the latest email in the Gmail thread. Withmemoryon, emails that were sent are also recorded in GUMem, so a new session will not follow up twice either. - Memory:
memoryhas DoAnything write the emails it reads, the emails it sends and the commitments on both sides automatically, filed by customer, and recall them before checking each customer. If a customer said "let's talk early next month", the agent will not follow up early. You never call GUMem methods yourself. - Waiting and expiry: interactions wait for Alice, which may take hours. When an interaction expires, the agent skips that customer and checks again the next day.
Check:
result.statusissucceeded,result.output.followedUplists today's follow-ups andskippedgives the reason for each customer skipped.- Every customer followed up is Followed up in Airtable with
Last contactedset to today; running again the same day sends nothing twice.
3. Across sessions: "Where did we leave off with this customer?"
This step answers the question Alice may ask at any time. Start a run with the same memory: DoAnything first recalls this customer's memory from GUMem and opens Gmail for the latest emails when needed. If a follow-up is due, it still asks Alice before sending.
const ask = await qoni.doAnything.run({
token: grant.token,
memory: { namespace: 'sales-follow-up', citeSources: true },
prompt: `
Where did we leave off with Northwind Labs? List the customer's needs and preferences, the commitments on both sides and the next step, each with its source email.
If a follow-up is due now, draft a reply in the original thread and ask me before sending.
Return only JSON: { summary, items: [{ text, sourceUrl }], nextStep }.
`,
})
const answer = await ask.wait({ onInteraction: handleInteraction })
console.log(answer.output)citeSources: trueattaches a source to every memory;sourceUrlpoints straight to the original email in Gmail, so your app only shows it as a link.- Keeping the follow-up going needs no separate flow: once the agent drafts the email it raises a
confirmation, which the samehandleInteractionfrom step 2 handles. - Memory holds only the emails and the needs, preferences, commitments and next steps distilled from them. Follow-up status and contact times live only in Airtable; GUMem never copies customer status.
Check: in a new session, asking "where did we leave off with Northwind?" returns the commitments and the next step, and each one opens the matching Gmail email.
Check the result
Before going live, confirm that:
- On the first run, Alice handled the Airtable sign-in, the Gmail sign-in and two-step verification in that order; later runs reuse the sign-in and only ask for confirmation before sending.
- Every email was confirmed by Alice; rejected emails are never sent.
- After several days, no incoming email leads to two follow-ups, and the follow-up status and contact times in Airtable match Gmail.
- Your server keeps
grant.grantId,grant.auditIdand therunIdof each run, so you can trace every email in Qoni Console back to the delegation Alice gave. - After Alice's GenAuth account is disabled, or a scope is removed from the AccessKey policy, the next day's silent delegation fails and the run does not start.
Next steps
- Read Personal Agent to see how an agent for individual users gets its own Identity, binds to the user and uses the same interaction types to complete tasks together with them.
- Read Qoni SDK: Interaction requests and business functions for the fields, actions and expiry of each interaction.
- Read How Memory Forms to see how the memory DoAnything writes into GUMem is distilled and recalled.