Guide13 min read

Build a Multi-Tenant AI Sales Agent (Where Every Rep's Agent Has Its Own Identity)

Build a multi-tenant AI sales agent on Naïve: each rep's agent researches leads, drafts with OpenRouter, sends from their own Gmail, and spends under approval.

/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
/how /how /how /how /how /how /how /how /how /how
Guide
TL;DR
  • Multi-tenant AI sales tools break the moment an agent needs to act inside each rep's own Gmail, CRM, and budget you'd have to build per-user OAuth storage, token encryption, an approval queue, and card issuing yourself
  • Naïve gives you those as primitives: a tenant user per rep, an Account Kit that defines their policy, per-user connections to 1,000+ apps, and human-in-the-loop approvals on high-stakes actions
  • Every call is scoped with naive.forUser(repId) and filtered by that rep's Account Kit Rep A's agent can never touch Rep B's inbox or card
  • The agent reasons and drafts with naive.llm.chat(), a full OpenRouter wrapper over 300+ models (anthropic/claude-sonnet-4.6, openai/gpt-5.2) billed in credits
  • Connecting a third-party account and issuing a virtual card are gated by default: the API returns 202 pending_approval and replays the action only after a human approves
  • The full arc provision rep, govern with a kit, connect Gmail + HubSpot, research, draft, send, spend with approval — is ~150 lines against the current SDK

Most "AI SDR" demos are a single OpenAI key, a prompt, and a hardcoded SMTP sender. That works for one person. The moment you try to sell it to a team, the model isn't the problem — identity and authority are:

  • Each rep's agent must act inside that rep's own Gmail and CRM, not a shared mailbox.
  • One rep's agent must never be able to read or act on another rep's data.
  • High-stakes actions — spending money, connecting a new account that grants data access — need a human in the loop, enforced when the action runs, not as an afterthought.

Without a platform, you end up building a per-user OAuth token store, encryption and rotation, a strict scoping layer, and a bespoke approval workflow before you write a single line of sales logic. That plumbing is the product — and it's exactly what Naïve ships as primitives.

This is a developer tutorial. Every code block runs against the current @usenaive-sdk/server, with signatures pulled straight from the docs. By the end you'll have a multi-tenant agentic SDR backend where each rep gets an isolated, governed AI agent.

Per-rep architecture: each sales rep is a tenant user governed by an Account Kit; the agent is scoped with naive.forUser(rep) and reaches the rep's own Gmail, HubSpot, and a capped virtual card — with connecting accounts and issuing cards frozen for human approval.

What we're building

  • A backend service that provisions an isolated AI agent per sales rep.
  • Each rep can connect their own Gmail and HubSpot through hosted OAuth.
  • The agent researches a lead, drafts personalized outreach with an OpenRouter model, sends from the rep's Gmail, and logs the activity to the rep's HubSpot.
  • The rep gets a capped virtual card for prospecting tools — and issuing it freezes for approval.
  • Everything is governed by one reusable Account Kit, so policy is defined once and applied to every rep.

The primitives in play, all from the docs:

PrimitiveRole in the sales agentDocs
UsersOne tenant user per rep — the isolation boundarynaive.users
Account KitsThe rep's policy: allowed apps, enabled primitives, approval rulesnaive.accountKits
ConnectionsPer-rep access to Gmail, HubSpot, Slack, and 1,000+ appsforUser(id).connections
LLMOpenRouter-backed reasoning + drafting across 300+ modelsforUser(id).llm
Web ResearchLead research from the live webPOST /v1/search
Virtual CardsCapped spend for prospecting tools, per repforUser(id).cards
ApprovalsHuman-in-the-loop on high-stakes actionsforUser(id).approvals

Step 0: Install and authenticate

You need a Naïve workspace key (nv_sk_...) from Studio → Settings → API keys. The SDK is server-only — keys are server secrets, never shipped to the browser.

npm install @usenaive-sdk/server
import { Naive } from "@usenaive-sdk/server";
 
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });
  • naive.* (the root client) acts on your default tenant user and exposes the control plane (naive.users, naive.accountKits).
  • naive.forUser(id).* returns the same data-plane surface bound to a specific rep.

That forUser scoping is the whole game. Read it as: "do this, as this rep, under this rep's policy."

Step 1: Define the SDR policy once, with an Account Kit

An Account Kit is a reusable policy template. Define your "SDR" tier a single time:

  • Allowlist the only apps an SDR's agent may connect: Gmail, HubSpot, Slack.
  • Enable the primitives an SDR needs: email, vault, cards.
  • Leave approval on for the high-stakes stuff (it's the default for cards and connecting services).
const sdrKit = await naive.accountKits.create({
  name: "SDR",
  primitives_config: {
    cards: { enabled: true, requiresApproval: true },
    email: { enabled: true },
    vault: { enabled: true },
    social: { enabled: false },
  },
  connections_config: {
    mode: "allowlist",
    toolkits: ["gmail", "hubspot", "slack"],
    requiresApproval: false,        // connecting is normally gated by default — opt out
    approvalToolkits: ["hubspot"],  // …but re-gate HubSpot specifically
  },
});
// sdrKit.id → reuse for every rep you onboard
  • mode: "allowlist" means an SDR can connect only Gmail, HubSpot, and Slack — try to connect Notion and the API returns forbidden.
  • cards.requiresApproval: true keeps every card issuance behind a human.
  • Connecting a third-party service is gated by default for tenant users. We set connections_config.requiresApproval: false to let low-risk connects (Gmail) through, then approvalToolkits: ["hubspot"] re-freezes granting the agent CRM access until a human approves.

Discover valid app slugs with GET /v1/toolkits?search= — the same catalog the dashboard's Account Kit editor uses. Never hardcode a slug you haven't confirmed exists.

Step 2: Provision a rep as a tenant user

A tenant user is one of your end-users. They never sign into Naïve — you manage them through the SDK. Create one per rep from your own signup flow and assign the SDR kit:

async function onboardRep(dbUser: { id: string; email: string; name: string }) {
  const rep = await naive.users.create({
    external_id: dbUser.id,        // your DB row id — stable, your source of truth
    email: dbUser.email,
    label: dbUser.name,
    account_kit_id: sdrKit.id,     // governed by the SDR policy from Step 1
  });
  return rep; // rep.id → the handle you scope every future call with
}

From here on, everything the rep's agent does goes through their scoped client:

const rep = naive.forUser(repId);

That single boundary is what makes the product multi-tenant-safe. naive.forUser("rep_a") and naive.forUser("rep_b") see entirely separate sets of connections, cards, and secrets. There is no shared mailbox, no shared budget, no cross-tenant read path to get wrong.

Step 3: Connect the rep's own Gmail and HubSpot

This is the step that's painful without the moat. The agent has to act inside the rep's real accounts. Connections handle the OAuth, token storage, and execution-time auth for 1,000+ apps — per user, filtered by the Account Kit.

connect() returns a hosted redirectUrl. You send the rep there; once they finish OAuth, the connection flips to ACTIVE.

const rep = naive.forUser(repId);
 
// Gmail is allowlisted and not in approvalToolkits → returns a redirect immediately
const { redirectUrl } = await rep.connections.connect("gmail", {
  callbackUrl: "https://yourapp.com/oauth/done",
});
// In your route handler: res.redirect(redirectUrl)

HubSpot is different — we put it in approvalToolkits, so granting the agent CRM access is gated. The connect call resolves to a pending approval instead of a redirect:

import { isPendingApproval } from "@usenaive-sdk/server";
 
const res = await rep.connections.connect("hubspot", {
  callbackUrl: "https://yourapp.com/oauth/done",
});
 
if (isPendingApproval(res)) {
  // Surface res.approval_id to the rep's manager. Nothing happened yet.
  console.log("CRM connect awaiting approval:", res.approval_id);
} else {
  // Approved/un-gated path: send the rep to res.redirectUrl
}

Check what a rep has connected at any time — this reads Naïve's local mirror, so it's cheap:

const connected = await rep.connections.connected();
// [{ toolkit: "gmail", status: "ACTIVE" }, ...]

Step 4: Research the lead

Before drafting, the agent gets eyes on the live web with the Web Research primitive. Use the lightest tool that works — web_search (1 credit) for a quick scan, read_url to extract a specific page. Both are plain authenticated REST calls:

const headers = {
  Authorization: `Bearer ${process.env.NAIVE_API_KEY!}`,
  "Content-Type": "application/json",
};
 
// web_search — POST /v1/search → { results, credits_used }
const searchRes = await fetch("https://api.usenaive.ai/v1/search", {
  method: "POST",
  headers,
  body: JSON.stringify({ query: "Acme Corp Series B funding 2026", count: 5 }),
});
const { results } = await searchRes.json();
 
// read_url — POST /v1/search/url → { url, content, credits_used }
const urlRes = await fetch("https://api.usenaive.ai/v1/search/url", {
  method: "POST",
  headers,
  body: JSON.stringify({
    url: results[0].url,
    extract: "recent funding, headcount, and tech stack",
  }),
});
const page = await urlRes.json();
// page.content → grounding for a personalized opener

This is grounding, not guesswork — the opener references something real and current, which is the difference between a reply and the spam folder.

Step 5: Draft the outreach with an OpenRouter model

The LLM primitive is a full wrapper over OpenRouter — one OpenAI-compatible endpoint routing to 300+ models, billed in Naïve credits from the exact cost OpenRouter reports. You don't hold an OpenRouter key; Naïve injects it server-side. The request/response bodies are exactly OpenRouter's, so model ids carry the provider prefix and you get fallback chains and provider routing for free.

const rep = naive.forUser(repId);
 
const draft = await rep.llm.chat({
  model: "anthropic/claude-sonnet-4.6",
  models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.2"], // fallback chain
  provider: { sort: "throughput", data_collection: "deny" }, // OpenRouter routing
  messages: [
    {
      role: "system",
      content:
        "You are an SDR. Write a 3-sentence cold email. Specific, no fluff, one clear ask.",
    },
    {
      role: "user",
      content: `Prospect research:\n${page.content}\n\nWrite the email.`,
    },
  ],
});
 
const emailBody = draft.choices[0].message.content;
console.log("credits used:", draft.credits_used);
  • model takes a provider-prefixed id — anthropic/claude-sonnet-4.6, openai/gpt-5.2, etc. Browse them free with rep.llm.models("claude").
  • models is an optional fallback chain OpenRouter tries in order.
  • Because the call is scoped to the rep and AccountKit-gated, per-rep usage is metered and attributable.

Already have OpenAI/OpenRouter client code? Point its baseURL at https://api.usenaive.ai/v1/proxy/openrouter and keep your code as-is — Naïve injects the key and bills your credits. The drop-in proxy is not Account-Kit gated, so use the typed forUser(id).llm routes (above) when you want per-rep enforcement.

Step 6: Send from the rep's inbox, log to the rep's CRM

Now execute real tools on the rep's connected accounts. The agent sends through the rep's own authenticated Gmail — so replies land in their inbox and deliverability rides on their domain, not a shared relay.

const rep = naive.forUser(repId);
 
await rep.connections.execute("gmail", "GMAIL_SEND_EMAIL", {
  recipient_email: "vp.eng@acme.com",
  subject: "Quick idea re: your Series B hiring",
  body: emailBody,
});

Don't guess a tool's name or arguments — discover them at runtime for whatever the rep connected:

const tools = await rep.connections.tools("hubspot");
// → the exact tool slugs + arg schemas HubSpot exposes, so you call the right one

The same execute pattern logs the touch back to the rep's CRM with the tool slug HubSpot reports. Two reps running this concurrently never collide: each execute is bound to its own scoped client and its own connected account.

Step 7: Give the rep a capped card — and watch approval kick in

Prospecting often needs paid data (enrichment, a LinkedIn export tool). Issue the rep a virtual card with a hard cap. Managed virtual cards require a cardholder once per company (Cards docs); prepaid gift cards cap at $150 without one. Because the SDR kit set cards.requiresApproval: true, the agent's attempt to issue it freezes — the API returns 202 pending_approval instead of creating a card.

The approval lifecycle: the agent's high-stakes call is frozen and returns 202 pending_approval with an approval_id; a human approves or denies; on approval the API replays the frozen action and records the result.

import { isPendingApproval } from "@usenaive-sdk/server";
 
const rep = naive.forUser(repId);
 
// Agent attempts to issue a $150-cap managed virtual card for prospecting tools
// (Create a cardholder once per company before managed_virtual — see Cards docs.)
const res = await rep.cards.create({
  name: "Prospecting tools",
  spending_limit_cents: 15000,
  provider: "managed_virtual",
});
 
if (isPendingApproval(res)) {
  // The card was NOT created. Surface this to the rep's manager.
  console.log("Pending:", res.approval_id, "—", res.title);
 
  // A human approves (from your dashboard, or the operator key):
  await rep.approvals.approve(res.approval_id);
 
  // The API replays the frozen cards.create and records the result
  const resolved = await rep.approvals.get(res.approval_id);
  console.log(resolved.status); // "executed" → result holds the new card + checkout_url
}

This is execution-time permission enforcement, and it matters that the API does it, not your code:

  • The action is frozen server-side the instant the agent calls it — there's no window where a half-authorized request slips through.
  • On approval, the API replays the exact frozen call. You never reconstruct the request or hold mutable state waiting for a human.
  • It's policy, not plumbing: flip requiresApproval on the Account Kit and the gate moves for every rep on that kit.

You can also block and poll instead of webhooking:

await rep.approvals.wait(res.approval_id); // resolves when approved/denied

What's gated by default for agent calls on tenant users, straight from the docs:

Actionaction_type
Issue a virtual cardcards.create
Top up a cardcards.topup
Purchase a domaindomains.purchase
Start KYCverification.start
Form / file a companyformation.create, formation.submit
Connect a 3rd-party serviceconnections.connect

Calls on your own default user run un-gated; only agent calls on real tenant users are gated. Set requiresApproval: false per primitive to opt out.

Step 8: Put it together — onboard a rep, run a sequence

The whole backend, end to end:

import { Naive, isPendingApproval } from "@usenaive-sdk/server";
 
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });
 
// One-time: define the SDR policy
const sdrKit = await naive.accountKits.create({
  name: "SDR",
  primitives_config: {
    cards: { enabled: true, requiresApproval: true },
    email: { enabled: true },
    vault: { enabled: true },
  },
  connections_config: {
    mode: "allowlist",
    toolkits: ["gmail", "hubspot", "slack"],
    requiresApproval: false,
    approvalToolkits: ["hubspot"],
  },
});
 
// Per rep: provision + govern
export async function onboardRep(dbUser: { id: string; email: string; name: string }) {
  const repUser = await naive.users.create({
    external_id: dbUser.id,
    email: dbUser.email,
    label: dbUser.name,
    account_kit_id: sdrKit.id,
  });
  const rep = naive.forUser(repUser.id);
 
  // Start Gmail OAuth — send the rep to redirectUrl from your route handler
  const gmail = await rep.connections.connect("gmail", {
    callbackUrl: "https://yourapp.com/oauth/done",
  });
  return { repId: repUser.id, gmailRedirect: gmail.redirectUrl };
}
 
// Per lead: research → draft → send → log
export async function runSequence(repId: string, prospect: { domain: string; to: string }) {
  const rep = naive.forUser(repId);
  const headers = {
    Authorization: `Bearer ${process.env.NAIVE_API_KEY!}`,
    "Content-Type": "application/json",
  };
 
  const { results } = await (
    await fetch("https://api.usenaive.ai/v1/search", {
      method: "POST",
      headers,
      body: JSON.stringify({ query: `${prospect.domain} news 2026`, count: 5 }),
    })
  ).json();
  const page = await (
    await fetch("https://api.usenaive.ai/v1/search/url", {
      method: "POST",
      headers,
      body: JSON.stringify({ url: results[0].url, extract: "recent company news" }),
    })
  ).json();
 
  const draft = await rep.llm.chat({
    model: "anthropic/claude-sonnet-4.6",
    models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.2"],
    messages: [
      { role: "system", content: "You are an SDR. 3-sentence cold email, one clear ask." },
      { role: "user", content: `Research:\n${page.content}` },
    ],
  });
 
  await rep.connections.execute("gmail", "GMAIL_SEND_EMAIL", {
    recipient_email: prospect.to,
    subject: "Quick idea",
    body: draft.choices[0].message.content,
  });
}
 
// On demand: issue a capped prospecting card (freezes for approval)
export async function issueProspectingCard(repId: string) {
  const rep = naive.forUser(repId);
  const res = await rep.cards.create({
    name: "Prospecting tools",
    spending_limit_cents: 15000,
    provider: "managed_virtual",
  });
  if (isPendingApproval(res)) return { status: "pending", approvalId: res.approval_id };
  return { status: "created", card: res };
}

That's a complete, multi-tenant agentic SDR backend. The sales logic is small; the platform carries identity, isolation, authority, and spend.

Why this can't exist without the moat

Strip Naïve out and rebuild Steps 2–7 yourself:

  • Per-rep OAuth for 1,000+ apps — token storage, refresh, and revocation, per user, for every integration you support.
  • Encryption at rest — a KMS-backed vault so a leaked row isn't a leaked inbox.
  • Hard tenant isolation — a scoping layer that is designed to block cross-tenant reads server-side, not just "we remembered the WHERE rep_id =."
  • An approval queue with replay — freeze a high-stakes action at execution time, surface it to a human, and replay the exact call on approval.
  • Per-rep spend — issue and cap real virtual cards, attributable to one rep.

Each of those is a project. Together they're the reason a serious multi-tenant AI sales product is hard. Naïve makes them forUser(id), an Account Kit, and isPendingApproval(res) — so you ship the agent, not the plumbing.

Hand the tools to the agent directly (optional)

Everything above is explicit orchestration. If you'd rather let an LLM drive the tool-use loop, agentTools() returns a ready meta-toolset bounded by the rep's Account Kit — the model discovers and runs connected apps and Naïve primitives itself, and sensitive methods still resolve to a pending_approval payload it relays to the human.

const kit = naive.forUser(repId).agentTools();
// kit.tools  → Anthropic tool-use definitions
// kit.handle(name, input) → dispatcher, AccountKit-gated server-side

Or hand the rep's agent a short-lived, scoped MCP session:

const session = await naive.forUser(repId).session();
console.log(session.mcp.url, session.mcp.headers);

Ship it

  1. Get a key at studio.usenaive.ai → API keys.
  2. Define the SDR Account Kit once — allowlist Gmail/HubSpot/Slack, require approval for cards and CRM connect.
  3. Provision a rep with naive.users.create({ account_kit_id }).
  4. Connect the rep's Gmail/HubSpot via the hosted redirectUrl.
  5. Run the loop — research, draft with an OpenRouter model, send, log.
  6. Issue a capped card and watch the approval gate freeze and replay it.
Frequently Asked Questions
Why can't I just build a multi-tenant sales agent with the OpenAI API and OAuth?+
You can build the reasoning loop, but the hard part is identity and authority. Each rep's agent must act inside that rep's own Gmail and CRM — which means storing and refreshing per-user OAuth tokens, encrypting them, scoping every call so one rep's agent can never touch another's data, and gating high-stakes actions (spending money, connecting new accounts) behind a human. Naïve ships those as primitives: tenant users, Account Kits, per-user connections, an encrypted vault, and an approvals queue. The OpenAI/OpenRouter call is the easy 10%; the moat is the other 90%.
How does Naïve isolate one rep from another?+
Every primitive — connections, cards, vault, llm — is scoped to a tenant user. You create one tenant user per rep with naive.users.create(), then make every call through naive.forUser(repId). That scoped client can only see and act on that rep's resources, and each call is additionally filtered by the rep's Account Kit. There is no way for Rep A's scoped client to reach Rep B's connected Gmail or issued card.
What is an Account Kit and why does the agent need one?+
An Account Kit is a reusable policy template. It declares which primitives are enabled (cards, email, vault, social) and which third-party apps a user may connect (allowlist, blocklist, or open), plus which actions require human approval. You define a kit once — say, an 'SDR' tier that allows Gmail, HubSpot, and Slack and requires approval to spend — and assign it to every rep. Change the kit and the policy changes for everyone on it.
Which actions require approval, and can I change that?+
By default, agent calls on real tenant users freeze for approval on: issuing or topping up a virtual card, purchasing a domain, starting KYC, forming or filing a company, and connecting a third-party service. When gated, the API returns 202 with status pending_approval and an approval_id instead of executing. A human approves or denies; on approval the API replays the frozen action. You override this per primitive in the Account Kit with requiresApproval, and calls on your own default user run un-gated.
Which models can the sales agent use, and how is it billed?+
The llm primitive is a full wrapper over OpenRouter, so you get 300+ models across Anthropic, OpenAI, Google, Meta, and Mistral through one endpoint — e.g. anthropic/claude-sonnet-4.6 or openai/gpt-5.2, with optional fallback chains and provider routing. The request/response bodies are exactly OpenRouter's (OpenAI-compatible). Naïve holds the OpenRouter key and bills the exact cost OpenRouter reports, converted to credits ($0.50 = 1 credit).
Do the reps ever sign into Naïve?+
No. Tenant users are not auth subjects — they never sign in, and you manage them entirely through the API/SDK/CLI. The rep signs into your product; your backend uses one Naïve workspace key (nv_sk_...) and scopes calls per rep. When a rep needs to authorize Gmail, you send them to the hosted redirectUrl that connections.connect() returns, and they complete OAuth there.
How does the agent send email from the rep's own inbox instead of a generic relay?+
Once the rep connects Gmail (connections.connect('gmail') → they finish OAuth at the hosted link), the agent calls naive.forUser(repId).connections.execute('gmail', 'GMAIL_SEND_EMAIL', { ... }). The email is sent through the rep's authenticated Gmail account, so replies land in their inbox and deliverability rides on their domain reputation — not a shared sender.
NT
Naïve Team

Building the autonomous company infrastructure.

Keep reading