- ›
A multi-tenant AI support tool breaks the moment an agent must act inside each customer's own helpdesk, inbox, and Stripe— you'd have to build per-customer OAuth storage, token encryption, a strict scoping layer, and a per-plan capability gate yourself - ›
Naïve ships those as primitives: a tenant user per customer, an Account Kit that defines their plan, per-user connections to 1,000+ apps, and human-in-the-loop approvals— all enforced server-side - ›The product's pricing tiers ARE Account Kits: a Starter kit allowlists only Gmail; a Resolve kit adds Slack + Stripe and gates connecting payments for approval
- ›Capability is enforced at execution time, not in your code: a Starter agent that calls Stripe gets forbidden back from the API, and connecting Stripe returns 202 pending_approval until a human approves
- ›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
- ›
Every action is scoped with naive.forUser(customerId) and written to a per-user audit log— Customer A's agent can never read Customer B's inbox or touch their Stripe
Most "AI customer support" demos are a single OpenAI key, a canned-reply prompt, and one hardcoded helpdesk token. That works for a demo. The moment you try to sell it as a product — where each of your customers points the agent at their own support inbox and their own Stripe — the model stops being the hard part. Identity and authority take over:
- Each customer's agent must act inside that customer's own Gmail and Stripe, never a shared mailbox or your platform's account.
- One customer's agent must never be able to read another customer's tickets or issue a refund on their Stripe.
- What the agent is allowed to do has to match the plan they pay for — and that limit must be enforced when the agent acts, not as a feature flag in your UI that a prompt-injected ticket could talk its way past.
Without a platform you end up building a per-customer OAuth token store, encryption and rotation, a hard tenant-isolation layer, and a per-plan capability gate before you write a single line of support 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 support backend where each customer gets an isolated agent whose powers are decided by their plan — and enforced by the API.
What we're building
- A backend service that provisions an isolated AI support agent per customer.
- Each customer connects their own Gmail (support inbox) and, on a paid plan, their own Stripe — through hosted OAuth.
- The agent reads an incoming ticket, classifies it and drafts a reply with an OpenRouter model, sends from the customer's Gmail, and — when warranted and permitted — issues a refund on the customer's Stripe.
- The plan tier is a reusable Account Kit: Starter agents can only triage and reply; Resolve agents can also touch payments — and the limit is enforced at execution time.
- Every action lands in a per-customer audit log.
The primitives in play, all from the docs:
| Primitive | Role in the support agent | Docs |
|---|---|---|
| Users | One tenant user per customer — the isolation boundary | naive.users |
| Account Kits | The plan tier: allowed apps, enabled tools, approval rules | naive.accountKits |
| Connections | Per-customer access to Gmail, Slack, Stripe, and 1,000+ apps | forUser(id).connections |
| LLM | OpenRouter-backed classification + drafting across 300+ models | forUser(id).llm |
| Approvals | Human-in-the-loop on connecting a payments app | forUser(id).approvals |
| Logs | Per-customer audit trail of every agent action | forUser(id).logs |
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 customer.
That forUser scoping is the whole game. Read it as: "do this, as this customer, under this customer's plan."
Step 1: Define the plan tiers as Account Kits
This is the move that makes the product multi-tenant and monetizable. An Account Kit is a reusable policy template that controls two things:
- Primitives — which native capabilities are enabled (here:
llm,vault). - Connections — which third-party apps a customer may connect (
open,allowlist, orblocklist), which specific tools within an app are enabled, and which connects require human approval.
Define your tiers once and assign customers to them.
Starter — triage only. Allowlist a single app (Gmail), and restrict it to reading and replying. No Slack. No Stripe. A Starter agent that tries to reach payments has no path to it.
const starterKit = await naive.accountKits.create({
name: "Support — Starter",
primitives_config: {
llm: { enabled: true },
vault: { enabled: true },
},
connections_config: {
mode: "allowlist",
toolkits: ["gmail"],
tools: {
gmail: { enable: ["GMAIL_FETCH_EMAILS", "GMAIL_SEND_EMAIL"] },
},
},
});Resolve — can move money. Allowlist Gmail, Slack, and Stripe. Connecting Stripe is high-stakes, so we re-gate it for approval even though we let low-risk connects through.
const resolveKit = await naive.accountKits.create({
name: "Support — Resolve",
primitives_config: {
llm: { enabled: true },
vault: { enabled: true },
},
connections_config: {
mode: "allowlist",
toolkits: ["gmail", "slack", "stripe"],
requiresApproval: false, // connecting is gated by default — opt low-risk apps out
approvalToolkits: ["stripe"], // …but re-freeze connecting a payments processor
},
});
// starterKit.id / resolveKit.id → reuse for every customer you onboardWhat each control does, straight from the docs:
mode: "allowlist"— the agent can connect only the listedtoolkits. Anything else returnsforbidden.tools.gmail.enable— a per-tool filter: within Gmail, the agent only ever sees the read + send tools.approvalToolkits: ["stripe"]— connecting Stripe is frozen for a human, returning202 pending_approvalinstead of a redirect.
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 customer 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 customer from your own signup flow and assign the kit that matches their plan:
async function onboardCustomer(account: {
id: string;
email: string;
name: string;
plan: "starter" | "resolve";
}) {
const customer = await naive.users.create({
external_id: account.id, // your DB row id — stable, your source of truth
email: account.email,
label: account.name,
account_kit_id: account.plan === "resolve" ? resolveKit.id : starterKit.id,
});
return customer; // customer.id → the handle you scope every future call with
}From here on, everything a customer's agent does goes through their scoped client:
const agent = naive.forUser(customerId);naive.forUser("cust_a") and naive.forUser("cust_b") see entirely separate sets of connections, secrets, and logs. There is no shared mailbox, no shared Stripe, and no cross-tenant read path to get wrong.
Step 3: Connect the customer's own inbox (and, on Resolve, Stripe)
This is the step that's painful without the moat. The agent has to act inside the customer'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 customer there; once they finish OAuth, the connection flips to ACTIVE.
const agent = naive.forUser(customerId);
// Gmail is allowlisted on every tier and not in approvalToolkits → redirect immediately
const { redirectUrl } = await agent.connections.connect("gmail", {
callbackUrl: "https://yourapp.com/oauth/done",
});
// In your route handler: res.redirect(redirectUrl)On a Resolve customer, connecting Stripe is different — we put it in approvalToolkits, so granting the agent the ability to move money is gated. The connect call resolves to a pending approval instead of a redirect:
import { isPendingApproval } from "@usenaive-sdk/server";
const res = await agent.connections.connect("stripe", {
callbackUrl: "https://yourapp.com/oauth/done",
});
if (isPendingApproval(res)) {
// Nothing happened yet. Surface res.approval_id to the account owner / your ops team.
console.log("Stripe connect awaiting approval:", res.approval_id);
} else {
// Approved / un-gated path: send the customer to res.redirectUrl to finish OAuth
}Check what a customer has connected at any time — this reads Naïve's local mirror, so it's cheap:
const connected = await agent.connections.connected();
// [{ toolkit: "gmail", status: "ACTIVE" }, ...]Step 4: The boundary, demonstrated — a Starter agent can't touch Stripe
Before wiring the loop, prove the moat. A Starter customer's kit allowlists only Gmail. If their agent tries to reach Stripe — because a clever ticket asked it to "just refund me" — the API rejects it. The check runs server-side, when the agent acts:
const starterAgent = naive.forUser(starterCustomerId);
try {
await starterAgent.connections.execute("stripe", "STRIPE_CREATE_REFUND", {
payment_intent: "pi_123",
amount: 4200,
});
} catch (err) {
// error code: "forbidden" — the Starter Account Kit doesn't permit the Stripe app.
// The agent has no path to payments. Not "we forgot a WHERE clause" — structurally blocked.
console.log(err.code); // "forbidden"
}This is execution-time permission enforcement, and it matters that the API does it, not your code:
- The boundary is the customer's Account Kit, evaluated inside Naïve on every call — there's no application path that "forgets" to check the plan.
- A prompt-injected ticket can ask the agent to do anything; the agent still can't call a tool its kit doesn't grant.
- It's policy, not plumbing: move
stripefrom one kit to another and the capability moves for every customer on that tier — no redeploy.
To upgrade a customer, reassign the kit (the new policy is live on the next call):
await naive.accountKits.assignUser(resolveKit.id, starterCustomerId);
// or: await naive.users.update(starterCustomerId, { account_kit_id: resolveKit.id });Step 5: Read the ticket and classify it with an OpenRouter model
Now the working loop, on a Resolve customer. First, pull unread support mail from the customer's own inbox. Don't guess a tool's name or arguments — discover them at runtime for whatever the customer connected:
const agent = naive.forUser(customerId);
const tools = await agent.connections.tools("gmail");
// → the exact tool slugs + arg schemas Gmail exposes (filtered to what the kit enables)
const inbox = await agent.connections.execute("gmail", "GMAIL_FETCH_EMAILS", {
query: "is:unread label:support",
max_results: 10,
});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.
Classify each ticket into an action — and have the model return strict JSON so your code can branch on it:
const ticket = inbox.messages[0]; // shape comes from the Gmail tool's response
const triage = await agent.llm.chat({
model: "anthropic/claude-sonnet-4.6",
models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.2"], // OpenRouter fallback chain
provider: { sort: "throughput", data_collection: "deny" }, // OpenRouter routing
response_format: { type: "json_object" },
messages: [
{
role: "system",
content:
"You are a support triage agent. Classify the ticket and return JSON: " +
'{ "category": "billing|bug|how-to|refund", "reply": "<draft reply>", ' +
'"refund_cents": <number or 0> }. Be concise and specific.',
},
{ role: "user", content: `Subject: ${ticket.subject}\n\n${ticket.body}` },
],
});
const action = JSON.parse(triage.choices[0].message.content);
console.log("credits used:", triage.credits_used);modeltakes a provider-prefixed id —anthropic/claude-sonnet-4.6,openai/gpt-5.2, etc. Browse them free withagent.llm.models("claude").modelsis an optional fallback chain OpenRouter tries in order if a provider is unavailable.- Because the call is scoped to the customer and AccountKit-gated, per-customer 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-customer enforcement.
Step 6: Reply from the customer's inbox; refund only if the plan allows
Send the drafted reply through the customer's own authenticated Gmail — so it threads with the original ticket and deliverability rides on their domain, not a shared relay:
const agent = naive.forUser(customerId);
await agent.connections.execute("gmail", "GMAIL_SEND_EMAIL", {
recipient_email: ticket.from,
subject: `Re: ${ticket.subject}`,
body: action.reply,
});If the ticket warrants a refund, the agent issues it on the customer's own Stripe — but only if the customer is on a tier whose kit permits it. The exact same call that returns forbidden for a Starter customer succeeds for a Resolve customer with Stripe connected:
if (action.category === "refund" && action.refund_cents > 0) {
// Confirm the tool slug + args from the catalog rather than hardcoding blindly
const stripeTools = await agent.connections.tools("stripe");
// The email won't carry a payment intent — resolve the charge from your own records
const order = await db.findOrderByEmail(ticket.from);
// On a Resolve customer with Stripe ACTIVE, this executes on THEIR Stripe.
// On a Starter customer, the same line throws `forbidden` — the kit blocks the app.
await agent.connections.execute("stripe", "STRIPE_CREATE_REFUND", {
payment_intent: order.stripePaymentIntentId,
amount: action.refund_cents,
});
}The point: the agent code is identical across tiers. What changes is the customer's Account Kit, and the API enforces it. Two customers running this loop concurrently never collide — each execute is bound to its own scoped client and its own connected account.
Step 7: Audit everything, per customer
Every primitive write and connection action emits an activity event scoped to the tenant user. Build a per-customer timeline, or an operator view across all customers — useful for support SLAs and for proving, after the fact, exactly what an agent did on whose behalf.
// One customer's recent tool executions
const { events } = await naive.forUser(customerId).logs.query({
action: "connection.execute",
limit: 50,
});
// [{ action: "connection.execute", actor_type: "agent", entity_id: "stripe", created_at: ... }, ...]# Operator view across all customers (e.g. every refund the platform issued)
curl "https://api.usenaive.ai/v1/logs?action=connection.execute" \
-H "Authorization: Bearer nv_sk_your_key"Because the log is keyed to the tenant user and records the actor_type (agent vs human), you get an attributable trail for free — no instrumentation in your own code.
Step 8: Put it together — onboard a customer, run a ticket
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 plan tiers
const starterKit = await naive.accountKits.create({
name: "Support — Starter",
primitives_config: { llm: { enabled: true }, vault: { enabled: true } },
connections_config: {
mode: "allowlist",
toolkits: ["gmail"],
tools: { gmail: { enable: ["GMAIL_FETCH_EMAILS", "GMAIL_SEND_EMAIL"] } },
},
});
const resolveKit = await naive.accountKits.create({
name: "Support — Resolve",
primitives_config: { llm: { enabled: true }, vault: { enabled: true } },
connections_config: {
mode: "allowlist",
toolkits: ["gmail", "slack", "stripe"],
requiresApproval: false,
approvalToolkits: ["stripe"],
},
});
// Per customer: provision + govern, then start their Gmail OAuth
export async function onboardCustomer(account: {
id: string; email: string; name: string; plan: "starter" | "resolve";
}) {
const user = await naive.users.create({
external_id: account.id,
email: account.email,
label: account.name,
account_kit_id: account.plan === "resolve" ? resolveKit.id : starterKit.id,
});
const agent = naive.forUser(user.id);
const gmail = await agent.connections.connect("gmail", {
callbackUrl: "https://yourapp.com/oauth/done",
});
return { customerId: user.id, gmailRedirect: gmail.redirectUrl };
}
// On a paid customer: connect Stripe (freezes for approval)
export async function connectStripe(customerId: string) {
const res = await naive.forUser(customerId).connections.connect("stripe", {
callbackUrl: "https://yourapp.com/oauth/done",
});
if (isPendingApproval(res)) return { status: "pending", approvalId: res.approval_id };
return { status: "redirect", url: res.redirectUrl };
}
// Per ticket: read → classify → reply → (maybe) refund
export async function handleTicket(customerId: string) {
const agent = naive.forUser(customerId);
const inbox = await agent.connections.execute("gmail", "GMAIL_FETCH_EMAILS", {
query: "is:unread label:support",
max_results: 1,
});
const ticket = inbox.messages[0];
if (!ticket) return { status: "empty" };
const triage = await agent.llm.chat({
model: "anthropic/claude-sonnet-4.6",
models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.2"],
response_format: { type: "json_object" },
messages: [
{
role: "system",
content:
'Classify the ticket. Return JSON: { "category": "billing|bug|how-to|refund", ' +
'"reply": "<draft>", "refund_cents": <number or 0> }.',
},
{ role: "user", content: `Subject: ${ticket.subject}\n\n${ticket.body}` },
],
});
const action = JSON.parse(triage.choices[0].message.content);
await agent.connections.execute("gmail", "GMAIL_SEND_EMAIL", {
recipient_email: ticket.from,
subject: `Re: ${ticket.subject}`,
body: action.reply,
});
// Permitted only on tiers whose kit allowlists Stripe; otherwise `forbidden`.
if (action.category === "refund" && action.refund_cents > 0) {
const order = await db.findOrderByEmail(ticket.from); // your own records
try {
await agent.connections.execute("stripe", "STRIPE_CREATE_REFUND", {
payment_intent: order.stripePaymentIntentId,
amount: action.refund_cents,
});
} catch (err) {
if (err.code === "forbidden") {
// Starter tier: escalate to a human instead of refunding
return { status: "escalated", reason: "refund not permitted on plan" };
}
throw err;
}
}
return { status: "resolved", category: action.category };
}That's a complete, multi-tenant agentic support backend. The support logic is small; the platform carries identity, isolation, per-plan authority, and audit.
Why this can't exist without the moat
Strip Naïve out and rebuild Steps 1–6 yourself:
- Per-customer 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 or a hijacked Stripe.
- Hard tenant isolation — a scoping layer that makes a cross-tenant read or refund structurally impossible, not just "we remembered the
WHERE customer_id =." - A per-plan capability gate enforced at execution time — so the limit holds even when the input is a hostile, prompt-injected support ticket, and a plan change is a policy edit, not a deploy.
- Human-in-the-loop with replay — freeze connecting a payments processor, surface it to a human, and replay the exact call on approval.
Each of those is a project. Together they're the reason a serious multi-tenant AI support 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 customer's Account Kit — the model discovers and runs connected apps itself, and out-of-tier or sensitive calls are still rejected or resolve to a pending_approval it relays to the human.
const kit = naive.forUser(customerId).agentTools();
// kit.tools → tool-use definitions, already filtered to this customer's plan
// kit.handle(name, input) → dispatcher, AccountKit-gated server-sideOr hand the customer's agent a short-lived, scoped MCP session:
const session = await naive.forUser(customerId).session();
console.log(session.mcp.url, session.mcp.headers);Ship it
- Get a key at studio.usenaive.ai → API keys.
- Define the tiers once — a Starter kit (Gmail read + send) and a Resolve kit (Gmail + Slack + Stripe, with Stripe connect gated).
- Provision a customer with
naive.users.create({ account_kit_id }). - Connect their Gmail (and, on Resolve, Stripe) via the hosted
redirectUrl. - Run the loop — fetch, classify with an OpenRouter model, reply, and refund only where the plan permits.
- Watch the boundary hold — a Starter agent's Stripe call returns
forbidden; connecting Stripe freezes for approval and replays on yes.
- 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
- Logs: usenaive.ai/docs/getting-started/logs
- Full documentation: usenaive.ai/docs
Why can't I just build a multi-tenant support agent with the OpenAI API and a few OAuth integrations?+
How does the plan tier actually restrict what the agent can do?+
What does 'execution-time permission enforcement' mean here?+
How does the agent send email from the customer's own inbox instead of a shared relay?+
Which models can the support agent use, and how is it billed?+
Do my customers ever sign into Naïve?+
How do I upgrade or downgrade a customer's capabilities?+
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.
The vault primitive: per-user encrypted storage for the secrets your agents hold — API keys, cookies, tokens — envelope-encrypted with a managed KMS.
Provision a real email address for every AI Employee with DKIM, SPF, and DMARC configured automatically. Send, receive, schedule, and triage — with deliverability built for agent-volume sending.
The new @usenaive-sdk/server is a single, Stripe-style TypeScript client for every Naïve primitive — email, cards, apps, LLM, vault, and more — with first-class multi-tenancy and a drop-in agent toolset. A getting-started guide.