Guide15 min read

Build a Multi-Tenant AI Support Agent (Where the Plan Tier Decides What It Can Touch)

Build a multi-tenant AI customer-support platform on Naïve: each customer's agent reads their inbox, drafts replies, and refunds only where the plan permits.

Guide
TL;DR
  • 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.

Per-customer architecture: each customer is a tenant user assigned to a plan-tier Account Kit; the agent is scoped with naive.forUser(customer) and reaches that customer's own Gmail, Slack, and Stripe — with connecting payments frozen for approval and out-of-tier apps rejected as forbidden.

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:

PrimitiveRole in the support agentDocs
UsersOne tenant user per customer — the isolation boundarynaive.users
Account KitsThe plan tier: allowed apps, enabled tools, approval rulesnaive.accountKits
ConnectionsPer-customer access to Gmail, Slack, Stripe, and 1,000+ appsforUser(id).connections
LLMOpenRouter-backed classification + drafting across 300+ modelsforUser(id).llm
ApprovalsHuman-in-the-loop on connecting a payments appforUser(id).approvals
LogsPer-customer audit trail of every agent actionforUser(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/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 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, or blocklist), 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 onboard

What each control does, straight from the docs:

  • mode: "allowlist" — the agent can connect only the listed toolkits. Anything else returns forbidden.
  • 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, returning 202 pending_approval instead of a redirect.

Capability tiers as Account Kits: the same agent code, governed by different kits. Starter is restricted to Gmail read + send; Resolve adds Slack and Stripe with payments connects gated for approval. The API enforces the boundary at execution time.

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"
}

Execution-time enforcement: an out-of-tier tool call returns forbidden, and a gated connect returns 202 pending_approval that a human resolves before the API replays it. The agent never holds a capability its plan doesn't grant.

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 stripe from 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);
  • model takes a provider-prefixed id — anthropic/claude-sonnet-4.6, openai/gpt-5.2, etc. Browse them free with agent.llm.models("claude").
  • models is 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 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-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-side

Or 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

  1. Get a key at studio.usenaive.ai → API keys.
  2. Define the tiers once — a Starter kit (Gmail read + send) and a Resolve kit (Gmail + Slack + Stripe, with Stripe connect gated).
  3. Provision a customer with naive.users.create({ account_kit_id }).
  4. Connect their Gmail (and, on Resolve, Stripe) via the hosted redirectUrl.
  5. Run the loop — fetch, classify with an OpenRouter model, reply, and refund only where the plan permits.
  6. Watch the boundary hold — a Starter agent's Stripe call returns forbidden; connecting Stripe freezes for approval and replays on yes.
Frequently Asked Questions
Why can't I just build a multi-tenant support agent with the OpenAI API and a few OAuth integrations?+
You can build the reasoning loop in an afternoon. The hard part is identity and authority: each customer's agent must act inside that customer's own inbox and Stripe, which means storing and refreshing per-customer OAuth tokens, encrypting them, scoping every call so one customer's agent can never touch another's data, and enforcing which capabilities each plan grants — at the moment the agent acts, not as a UI toggle you hope nobody bypasses. Naïve ships those as primitives: tenant users, Account Kits, per-user connections, an encrypted vault, and an approvals queue. The model call is the easy 10%; the moat is the other 90%.
How does the plan tier actually restrict what the agent can do?+
Each plan is an Account Kit — a reusable policy template that declares which third-party apps a user may connect (open, allowlist, or blocklist), which specific tools within an app are enabled, and which actions require human approval. You assign a customer to a kit, and every call through naive.forUser(customerId) is filtered by it server-side. A Starter customer whose kit allowlists only Gmail will get forbidden if their agent tries to reach Stripe — the restriction is enforced by the API, not by your application code.
What does 'execution-time permission enforcement' mean here?+
The gate runs when the agent acts, inside Naïve, not as a check you write before calling. If a customer's Account Kit doesn't permit an app, connections.execute returns forbidden. If connecting an app is gated for approval, connections.connect returns 202 pending_approval and the action is frozen until a human approves — at which point the API replays the exact frozen call. There's no window where a half-authorized request slips through, and changing the policy is a one-line Account Kit edit that applies to every customer on that tier.
How does the agent send email from the customer's own inbox instead of a shared relay?+
Once the customer connects Gmail (connections.connect('gmail') → they finish OAuth at the hosted link), the agent calls naive.forUser(customerId).connections.execute('gmail', 'GMAIL_SEND_EMAIL', { ... }). The reply is sent through the customer's authenticated Gmail account, so it threads correctly with the original ticket and deliverability rides on their domain — not a shared sender that lands support replies in spam.
Which models can the support 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 OpenAI-compatible 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. Naïve holds the OpenRouter key and bills the exact cost OpenRouter reports, converted to credits ($0.50 = 1 credit).
Do my customers 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. Your customer signs into your product; your backend uses one Naïve workspace key (nv_sk_...) and scopes calls per customer. When a customer needs to authorize Gmail or Stripe, you send them to the hosted redirectUrl that connections.connect() returns.
How do I upgrade or downgrade a customer's capabilities?+
Reassign them to a different Account Kit with naive.accountKits.assignUser(kitId, userId), or update the kit itself with naive.accountKits.update(kitId, { connections_config }). Because every call is filtered by the kit at execution time, the new policy takes effect immediately for every customer on it — no redeploy, no per-customer config to keep in sync.
NT
Naïve Team

Building the autonomous company infrastructure.

Keep reading