- ›
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.
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:
| Primitive | Role in the sales agent | Docs |
|---|---|---|
| Users | One tenant user per rep — the isolation boundary | naive.users |
| Account Kits | The rep's policy: allowed apps, enabled primitives, approval rules | naive.accountKits |
| Connections | Per-rep access to Gmail, HubSpot, Slack, and 1,000+ apps | forUser(id).connections |
| LLM | OpenRouter-backed reasoning + drafting across 300+ models | forUser(id).llm |
| Web Research | Lead research from the live web | POST /v1/search |
| Virtual Cards | Capped spend for prospecting tools, per rep | forUser(id).cards |
| Approvals | Human-in-the-loop on high-stakes actions | forUser(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/serverimport { 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 onboardmode: "allowlist"means an SDR can connect only Gmail, HubSpot, and Slack — try to connect Notion and the API returnsforbidden.cards.requiresApproval: truekeeps every card issuance behind a human.- Connecting a third-party service is gated by default for tenant users. We set
connections_config.requiresApproval: falseto let low-risk connects (Gmail) through, thenapprovalToolkits: ["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 openerThis 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);modeltakes a provider-prefixed id —anthropic/claude-sonnet-4.6,openai/gpt-5.2, etc. Browse them free withrep.llm.models("claude").modelsis 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
baseURLathttps://api.usenaive.ai/v1/proxy/openrouterand 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 typedforUser(id).llmroutes (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 oneThe 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.
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
requiresApprovalon 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/deniedWhat's gated by default for agent calls on tenant users, straight from the docs:
| Action | action_type |
|---|---|
| Issue a virtual card | cards.create |
| Top up a card | cards.topup |
| Purchase a domain | domains.purchase |
| Start KYC | verification.start |
| Form / file a company | formation.create, formation.submit |
| Connect a 3rd-party service | connections.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-sideOr 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
- Get a key at studio.usenaive.ai → API keys.
- Define the SDR Account Kit once — allowlist Gmail/HubSpot/Slack, require approval for cards and CRM connect.
- Provision a rep with
naive.users.create({ account_kit_id }). - Connect the rep's Gmail/HubSpot via the hosted
redirectUrl. - Run the loop — research, draft with an OpenRouter model, send, log.
- Issue a capped card and watch the approval gate freeze and replay it.
- Start here: studio.usenaive.ai
- SDK quickstart: usenaive.ai/docs/sdk/quickstart
- Users: usenaive.ai/docs/getting-started/users
- Account Kits: usenaive.ai/docs/getting-started/account-kits
- Connections: usenaive.ai/docs/getting-started/connections
- Approvals: usenaive.ai/docs/getting-started/approvals
- LLM (OpenRouter): usenaive.ai/docs/getting-started/llm
- Full documentation: usenaive.ai/docs
Why can't I just build a multi-tenant sales agent with the OpenAI API and OAuth?+
How does Naïve isolate one rep from another?+
What is an Account Kit and why does the agent need one?+
Which actions require approval, and can I change that?+
Which models can the sales agent use, and how is it billed?+
Do the reps ever sign into Naïve?+
How does the agent send email from the rep's own inbox instead of a generic relay?+
Building the autonomous company infrastructure.
The connections primitive gives each end-user's agents authenticated access to 1,000+ third-party apps — per user, gated by the Account Kit, in one surface.
Issue virtual cards for your agents — a prepaid gift card or a managed virtual card — funded via checkout, capped by a spending limit, and fully audited.
AI workforce orchestration with a CEO agent, kanban task board, strategic objectives, cron scheduling, and persistent memory — so your agent can plan, delegate, and execute multi-step workflows without manual supervision.
Launch the Naïve Agent SDK: one interface for agent identity, 100+ primitives, scoped cards, and real-world standing — multi-tenant, with audit logs, spend limits, and per-user MCP sessions.