Skip to content

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.

Toward the agentic webA layered model: a person delegates scoped, audited authority to the agent, which works on the web in hosted cloud browsers and hands the results back.HTMLWebAgentHumanDelegate to agentEvery step auditedResults to humanHosted cloud browsersNo API neededSign-ins carry overRuns tasks in parallel

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 needsWhyStep
The rep's own delegationThe agent acts in Alice's name; the delegation limits what it may do and for how long1
A run that works with the repOpens 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 email2
Customer memory across sessionsRemembers needs, preferences, commitments and next steps, avoids duplicate follow-ups and points back to the source emails2 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.

DelegationObtained silently every day
  1. 1GenAuthDelegate to the agentSilently, in the rep’s own name; no consent page
Today’s tokenhanded to the daily run
Every dayOnce per business day

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:
FieldPurpose
OwnerThe email of the rep who owns this customer. The agent only handles the rep's own customers
Contact emailThe customer contact's email, used to find the threads in Gmail
StageThe sales stage, for example Proposal or Negotiation
Follow-up status, Last contactedThe 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:

bash
npm install @qoniai/qoni
ts
import { 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.

ts
// 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 userId decides 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:

  1. AgentOpens the Airtable customer table in a controlled browser
  2. site_loginWaits for the repFirst run only
    Sign in to Airtable
    The 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()
  3. AgentReads the customers Alice owns, then opens Gmail
  4. site_loginWaits for the repFirst run only
    Sign in to Gmail
    Also signed in by the rep. DoAnything reuses the saved sign-in on later runs while it stays valid.
    Your app callshandle.openLogin()handle.confirmSignedIn()
  5. AgentGoogle sees a new device and asks to verify the sign-in
  6. take_controlWaits for the repFirst run only
    Two-step verification
    The rep confirms on their own phone or types the code, then hands the browser back to the agent.
    Your app callshandle.connectControl()handle.releaseControl()
  7. AgentChecks each customer against the Gmail threads and recalled memory, skips anyone contacted today, and drafts a reply for Northwind
  8. confirmationSend gate · once per email
    Confirm before sending
    The summary names the customer, the reason and the draft. The agent clicks “Send” only after the rep confirms.
    Your app callshandle.confirm()handle.reject()
  9. 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:

ts
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.

ts
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
    }
  }
}
ts
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.id and payload. In this scenario confirmation repeats the most: once per follow-up email, so as many times as there are customers to follow up today. On the first run site_login also 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 pending to active and resolved, and event replay after a reconnect all call onInteraction again. That is why the handler only handles the pending status and remembers each handled request.id, so the same email is never pushed to Alice twice or sent twice. Your UI should likewise update the same card by request.id instead 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_login again 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 contacted and the latest email in the Gmail thread. With memory on, emails that were sent are also recorded in GUMem, so a new session will not follow up twice either.
  • Memory: memory has 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.status is succeeded, result.output.followedUp lists today's follow-ups and skipped gives the reason for each customer skipped.
  • Every customer followed up is Followed up in Airtable with Last contacted set 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.

ts
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: true attaches a source to every memory; sourceUrl points 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 same handleInteraction from 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.auditId and the runId of 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 ​