← Blog
GuideJuly 3, 2026Updated September 7, 20268 min read

Building AI agents into your SaaS: the multi-tenant playbook

How to give every SaaS customer an isolated, governed agent: one organization per customer, a budgeted agent, allow/ask/deny policies, personas, a write-only vault, and webhooks back into your product.

Dennis Zax· CTO, Naïve

TL;DR

  • Multi-tenant agents mean one organization per customer, not one shared agent with a system prompt that says whose data it may touch.
  • Every organization's data is isolated and scoped by organization id on every request, and an API key reaches exactly the one organization it was minted in.
  • An agent is a versioned config with a mandatory USD budget; a session is one durable run of it, priced before every model call.
  • Per-tool allow / ask / deny policies are enforced at the tool-call boundary, so the risky steps pause for a human and the rest run.
  • Personas give the agent a real email, phone number and domain to act from; the vault holds credentials the model never sees.
  • Results come back into your product through signed webhooks and a typed structured_output, not by parsing transcripts.

What a multi-tenant agent product actually needs

If you are adding agents to a SaaS product, the interesting question is not which model to use. It is what happens when a thousand customers each have an agent acting on their behalf, spending their money and holding their credentials. The model is the easy part. The hard part is the boundary around it: whose data the agent may read, what it may do without asking, how much it may spend, who it acts as, and how the result gets back into your product.

The tempting shortcut is one shared agent with a system prompt that says "only act for customer X". That works in a demo and fails in production: one prompt injection or one mixed-up variable leaks a tenant's data or spend into another's. Isolation has to be structural, enforced below the model.

This guide is the current playbook for building that on Vetta, Naïve's managed agent. It replaces our earlier posts on multi-tenant agents and tenant isolation, which now redirect here.

The building blocks

Six concerns show up in every multi-tenant agent product. Here is what teams build by hand, and the primitive that replaces it.

ConcernThe DIY stackOn Vetta
IsolationPer-tenant scoping in every query and log lineOne organization per customer, scoped by organization id on every request
SpendToken metering plus a kill switchA mandatory USD budget on every agent, checked pre-flight
PermissionsInstructions in the system promptPer-tool allow / ask / deny policies enforced at the tool-call boundary
Acting as the customerShared bot inboxes and personal phonesA persona with its own email, phone and domain
SecretsA secrets manager plus careful redactionA write-only vault injected at the network boundary
ResultsPolling and transcript parsingSigned webhooks and typed structured outputs

The rest of this post takes them in the order you will wire them.

Step 1: one organization per customer

Everything on Vetta belongs to an organization: the billing entity that holds the credit balance and owns every agent, computer, skill and identity. Tenancy is logical multi-tenancy. Every organization's data is isolated per organization and scoped by organization id on every request, and an API key reaches exactly the one organization it is bound to.

So the tenancy model for a SaaS is simple: create an organization per customer, and keep the key that comes back in your own secrets store, keyed by your customer id.

tenant.ts
import { randomUUID } from "node:crypto";
import { createClient } from "@usenaive-sdk/vetta";
 
const root = createClient({
  baseUrl: "https://api.vetta.sh",
  apiKey: process.env.VETTA_API_KEY!,
  fetch: globalThis.fetch,
  idempotencyKey: () => randomUUID(),
});
 
// One organization per customer. The reply carries the organization and
// its first admin key; api_key.secret is returned here and never again.
const { organization, api_key } = await root.orgs.create({ name: "Acme" });
await secrets.put(`vetta:${customerId}`, api_key.secret);
 
// Every later call for this customer runs through a client bound to their key.
const acme = createClient({
  baseUrl: "https://api.vetta.sh",
  apiKey: api_key.secret,
  fetch: globalThis.fetch,
  idempotencyKey: () => randomUUID(),
});

The same thing from the CLI is vetta org create --name "Acme". Keys can also carry scopes such as agents:write or sessions:write; a valid key acting outside its scopes gets a 403 forbidden, not a partial result.

A bug in your prompt or your application code cannot cross tenants, because the key it is holding cannot see them.

Step 2: an agent with a budget it cannot exceed

An agent is a reusable, versioned configuration: model, system prompt, tools, skills, harness, default completion window and a required budget. Every change mints a new version; running sessions keep the version they started on.

The budget is not optional. You cannot create an agent without a period cap, a per-task ceiling and a period, and the values are integer micro-USD on the wire (1 USD is 1,000,000).

agent.ts
const agent = await acme.agents.create({
  name: "Acme Support",
  model: "zai-org/GLM-5.2-FP8",
  harness: "pi",
  system: "You handle Acme's inbound support tickets.",
  budget: {
    cap_micro_usd: 50_000_000,   // 50 USD per period
    max_task_micro_usd: 2_000_000, // 2 USD per session
    period: "month",
  },
  tools: {
    default_config: { permission: "deny" },
    configs: {
      web_fetch: { enabled: true, permission: "allow" },
      browser:   { enabled: true, permission: "ask" },
    },
  },
});

Before every model call, Vetta prices the call and checks it against four limits in order: the organization balance, the agent's period cap, the task ceiling and the session budget. A call that would breach any of them is refused with a budget.exceeded event and the session pauses with a budget_paused stop reason instead of overrunning. Read the budgets docs for the full order of checks.

Step 3: allow, ask, deny

The tools block above is the policy layer. Each tool gets one of three permissions, and an unlisted tool takes default_config.permission:

  • allow runs the tool without pausing.
  • ask emits a tool.confirm event and pauses the session with an awaiting_approval stop reason until a human allows or denies the call.
  • deny removes the tool from the model's available tools entirely; the model never sees it.

Policies are enforced at the tool-call boundary, before execution, not by asking the model to behave. Organization defaults and agent overrides are the two layers, and the most specific one wins.

A held session is idle with stop_reason: "awaiting_approval" and the blocked call in pending_actions. Answering it is one call from your own approval UI:

for (const action of session.pending_actions) {
  await acme.sessions.confirmTool(session.id, {
    tool_call_id: action.tool_call_id,
    decision: "allow",
  });
}

We cover the approvals loop end to end in How to add human approval to an AI agent.

Step 4: give the agent someone to be

A customer's agent usually has to act as that customer: send email from their domain, receive a verification code. On Vetta that actor is an identity, also called a persona: a named, durable actor that can own domains, email inboxes, phone numbers, OAuth connections and its own vault.

A persona's endpoints are concrete addresses and numbers, not boolean capabilities. The relationship with agents is many-to-many: one persona can be shared by several agents, and one agent can hold several personas and pick one per session.

persona.ts
const identity = await acme.identities.create({
  name: "Acme Billing",
  description: "Billing desk persona for Acme's support agent",
});
 
await acme.identities.attach(agent.id, identity.id);
 
const session = await acme.sessions.create({
  agent_id: agent.id,
  identity: identity.id,
  message: "Reply to ticket #4821 and confirm the refund status.",
});

The CLI equivalents are vetta identity create, vetta identity attach --agent <agent> --identity <identity> and vetta session create --agent <agent> --identity <identity>. See the personas docs for how endpoints and connections hang off an identity.

Step 5: credentials the model never sees

Per-customer credentials are where DIY agent stacks leak. The moment a raw API token enters the prompt, a prompt injection can exfiltrate it. Vetta's vault is write-only: a credential's value travels once, on the create call, and is sealed server-side. There is no read or reveal route. The value is injected at the network boundary, outside the sandbox, when the agent reaches the server the credential is bound to, so nothing sensitive enters the model's context, the transcript or your logs.

vault.ts
const vault = await acme.vaults.create({
  display_name: "Acme Billing",
  identity_id: identity.id,
});
 
await acme.vaults.credentials.create(vault.id, {
  kind: "static_bearer",
  key: "helpdesk",
  value: process.env.ACME_HELPDESK_TOKEN!,
  mcp_server_url: "https://mcp.helpdesk.example/mcp",
});

The static_bearer and mcp_oauth kinds are keyed by mcp_server_url and injected when the agent connects to that server; the env_var kind, substituted on general sandbox egress for one bound host, is coming soon. Binding the vault to the persona keeps one customer's credentials attached to one customer's actor. Rotation is create-new then delete-old; there is no update method, on purpose. We wrote up the design in Introducing Vault.

Step 6: results back into your product

A session is one durable run of an agent. It can pause, resume, stream events, take follow-up input and be cancelled, and it carries its own budget. For a product integration you want a typed result, and a push when it is ready.

The typed result is a structured output. Attach a JSON Schema on the agent or the session, and the document lands on the idle session as structured_output. Set structured_output_required: true to turn best effort into a hard requirement.

session.ts
const session = await acme.sessions.create({
  agent_id: agent.id,
  identity: identity.id,
  message: "Triage ticket #4821 and report the outcome.",
  metadata: { ticket_id: "4821" },
  output_schema: {
    type: "object",
    properties: {
      ticket_id: { type: "string" },
      resolved: { type: "boolean" },
      summary: { type: "string" },
    },
    required: ["ticket_id", "resolved"],
  },
  structured_output_required: true,
});

The push is a webhook. Subscribe an HTTPS endpoint to the events you care about once per organization:

await acme.webhooks.create({
  url: "https://app.example.com/hooks/vetta",
  events: ["session.idle", "message.completed", "budget.exceeded"],
});

Each delivery is a signed JSON envelope, { id, type, created_at, data }, and for session.idle the data carries the session_id, the stop_reason and the structured_output. Verify the HMAC-SHA256 signature in Vetta-Signature (v1=<hex>) over {timestamp}.{raw_body}, reject anything whose Vetta-Timestamp is more than 300 seconds old, and dedupe on the envelope id, which is stable across redeliveries. The metadata you set on the session is stored on it and echoed on its events, so a handler that reads the session back by session_id gets the ticket_id with it, without a lookup table.

Revocation is part of the design

Because every piece of access is a first-class object, taking it away is a first-class call too. Revoke a customer's key with keys.revoke, detach a persona with identities.detach, delete a vault credential, or cancel a running session with sessions.cancel. None of these touch another customer's organization, because nothing in the path is shared between organizations. How to revoke AI agent access instantly walks through each one.

Pitfalls we still see

  • Isolation by prompt, not by platform. "Only act for customer X" is a suggestion. An organization per customer, with a key that can only see that organization, is a boundary.
  • One key for every customer. A single key with every tenant behind it turns one leak into a breach of every tenant. Mint per-organization keys and scope them.
  • allow as the default. Start from default_config: { permission: "deny" }, allow the read-only tools, and put ask on anything that sends, pays or deletes.
  • Secrets in the context window. If a credential can be printed by the model, it eventually will be. Vault it instead.
  • Parsing transcripts for results. Attach an output schema and read structured_output from the session.idle webhook instead.

Where to start

Pick one customer flow and make it multi-tenant end to end. Create an organization for one customer, create an agent with a small budget and a deny-by-default toolset, attach a persona, vault one credential, and run one session with an output schema and a webhook. Once that flow is isolated per customer, every other flow follows the same shape. The TypeScript SDK reference lists every method used above.

FAQ

How do I build multi-tenant AI agents into my SaaS?
Create one Vetta organization per customer and keep that organization's API key in your own secrets store, keyed by customer id. Everything the agent touches (agents, sessions, personas, vault, files, credit balance) lives inside that organization, and every request is scoped by organization id, so one customer's agent cannot read another's data through your code.
How do I put a spending limit on a customer's agent?
Budgets are not optional on Vetta: an agent cannot be created without a cap, a per-task ceiling and a period. Every model call is priced before it runs and refused if it would breach the organization balance, the agent cap, the task ceiling or the session budget, and the session pauses with a budget_paused stop reason instead of overrunning.
How do I keep customer credentials out of the model?
Store them as vault credentials. A credential's value travels once, on the create call, and is sealed server-side; there is no read or reveal route, and the value is injected at the network boundary when the agent reaches the server it is bound to, so it never enters the model's context. Rotation is create-new then delete-old.
How does the agent's result get back into my product?
Attach a JSON Schema to the agent or the session. When the session goes idle, the typed document is on the session as structured_output and inside the session.idle webhook payload, so your handler can write the result straight into your database without prompt-parsing.