- ›A usage-metered SaaS is the textbook case for execution-time permissions: the tier a customer pays for must decide, at call time, what their agent can do and how much of it
- ›This build wires a multi-tenant AI SEO/AEO product on Naïve where a plan maps to an Account Kit (which primitives are on) plus per-primitive quotas (how many calls per period)
- ›
The moat: you write zero usage counters and zero feature flags— Naïve meters every metered call (`seo`, `aeo`, `email`) and returns `429 rate_limited` when a tenant exceeds quota, and `forbidden` when the tier never had that primitive - ›
One `setSubscription({ planKey, assignKit })` call— driven from your Stripe webhook — swaps a tenant's permissions and quotas atomically, so upgrades and downgrades take effect on the very next request - ›The exact same agent code runs for every customer; `naive.forUser(tenantId)` scopes usage, quota, and capability to that one tenant
Every SaaS that sells tiers has the same unglamorous back office: a plan enables some features, caps usage on others, and the moment a customer hits their limit the product has to say no — cleanly, at the exact call, for every customer at once.
That back office is exactly what an AI product cannot skip. An SEO agent that researches keywords is useful. An SEO agent that a free-tier customer can run ten thousand times, or that quietly calls a primitive the customer never paid for, is a billing incident. The tier has to be enforced where the work happens — at execution time — not by a hopeful if statement in your agent loop.
This tutorial builds a multi-tenant AI SEO/AEO SaaS on Naïve — the autonomous company infrastructure for governed agents — where that back office is a set of primitives instead of your code. It pairs with the AgentSEO keyword research loop and brand mention tracking use cases on the same /seo and /aeo primitives.
- A plan maps to an Account Kit — the tier decides which primitives (
seo,aeo,email) are even enabled. - A plan carries quotas — the tier decides how many calls per period each primitive gets.
- Naïve meters and enforces — every metered call records usage; overruns return
429 rate_limited; disabled primitives returnforbidden. You write neither the counter nor the check.
You could build the SEO agent itself in an afternoon. The part that makes it a product you can charge for — per-customer metering, tier-scoped capabilities, and enforcement that holds no matter what the agent code tries — is the part Naïve gives you as the Customer Billing primitive.
What you'll build
A product an agency or a marketing team subscribes to. Each customer is a tenant, and for each tenant the agent runs one job — a content brief for a target keyword:
- Research the keyword with the
seoprimitive — search volume and SERP competitors. - Audit AI-search visibility with the
aeoprimitive — how ChatGPT answers the query and who it cites. - Compose a structured brief with the
llmprimitive (OpenRouter wrapper). - Deliver the brief by email with the
emailprimitive — on tiers that include it.
The same code runs for every customer. What changes per customer is their plan, and the plan is enforced by Naïve:
| Primitive | Role in the build | Metered? | Docs |
|---|---|---|---|
/account-kits | Capability tier — which primitives a tenant may use | — | Account Kits |
/customer-billing | Plans (kit + quotas), subscriptions, usage rollup | — | Customer Billing |
/seo | Keyword volume + SERP competitor research | yes (seo) | SEO |
/aeo | AI-search visibility (LLM responses, mentions) | yes (aeo) | GEO/AEO |
/llm | OpenRouter wrapper — compose the structured brief | no (credits) | LLM |
/email | Deliver the brief to the customer | yes (email) |
Metered primitives are
seo,aeo, and429 rate_limitedwhen the tenant exceeds its plan quota. This is straight from the Customer Billing docs, not invented for this post.
Step 0: Install and authenticate
The whole build runs on the Naïve server SDK.
npm install @usenaive-sdk/serverimport { Naive, NaiveError } from "@usenaive-sdk/server";
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });Naiveis the client. Control-plane calls (naive.accountKits,naive.plans) manage your product's tiers.naive.forUser(tenantId)scopes every primitive call to one customer — this is how one code base serves every tenant, each with their own kit, quota, and usage.NaiveErroris how the moat surfaces in code: it carriesstatus,code, andhint. You will branch on429 rate_limitedandforbidden.
Step 1: Define the tiers as Account Kits
The tier is a capability boundary before it is a price. On Naïve that boundary is an Account Kit — a policy template that decides which primitives are enabled. Define one kit per tier.
// FREE: keyword research only. aeo and email are OFF — not rate-limited, OFF.
const freeKit = await naive.accountKits.create({
name: "SEO Free",
primitives_config: {
seo: { enabled: true },
aeo: { enabled: false },
email: { enabled: false },
},
});
// GROWTH: research + AI-search audit. Still no outreach email.
const growthKit = await naive.accountKits.create({
name: "SEO Growth",
primitives_config: {
seo: { enabled: true },
aeo: { enabled: true },
email: { enabled: false },
},
});
// SCALE: the full brief + email delivery.
const scaleKit = await naive.accountKits.create({
name: "SEO Scale",
primitives_config: {
seo: { enabled: true },
aeo: { enabled: true },
email: { enabled: true },
},
});primitives_configis the exact field from the Account Kits guide: a primitive slug maps to{ enabled }(and optionallyrequiresApproval).- A primitive set to
enabled: falseis unreachable for any tenant on that kit. The agent code doesn't check the tier — the kit does, at execution time.
Step 2: Turn each kit into a plan with quotas
A kit says what; a plan adds how much. The Customer Billing primitive's plans map a plan key to an Account Kit plus per-primitive quotas.
await naive.plans.upsert({
key: "free",
name: "Free",
accountKitId: freeKit.id,
quotas: { seo: 50 }, // 50 keyword lookups / month, nothing else
period: "month",
});
await naive.plans.upsert({
key: "growth",
name: "Growth",
accountKitId: growthKit.id,
stripePriceId: "price_growth_monthly", // your Stripe price
quotas: { seo: 2000, aeo: 500 },
period: "month",
});
await naive.plans.upsert({
key: "scale",
name: "Scale",
accountKitId: scaleKit.id,
stripePriceId: "price_scale_monthly",
quotas: { seo: 20000, aeo: 5000, email: 2000 },
period: "month",
});
const { plans } = await naive.plans.list();quotasmaps a primitive slug to its max calls perperiod(monthorday) — verbatim from the Customer Billing overview.accountKitIdis the kit assigned to a tenant when they subscribe — so tier permissions and tier quotas live in one object.stripePriceIdis optional and is your Stripe price; Naïve owns plan → permissions → quota → usage, your app still owns the actual charge.
Step 3: Provision a tenant and subscribe them
Each customer is a tenant user. On signup, provision the user and seed a subscription — seeding a free-plan subscription is what makes every customer metered from call one.
// Your app just signed up "Northwind Marketing".
const tenant = await naive.users.create({
external_id: "northwind_db_uuid", // your own DB id
email: "ops@northwind.example",
label: "Northwind Marketing",
});
// Seed the free plan. assignKit (default true) assigns the plan's kit in the
// same call — permissions + quotas applied together.
await naive.forUser(tenant.id).billing.setSubscription({
planKey: "free",
status: "active",
});setSubscriptionis the one call your Stripe webhook drives. ItsassignKitdefaults totrue, so it also assigns the plan's Account Kit — one call applies tier permissions and quotas.- The tenant now has
seoenabled with a 50-call/month quota, andaeo/emaildisabled. No feature flag in your database; the plan is the source of truth.
Step 4: The agent job — the same code for every tenant
Here is the whole job. Notice it does not ask what tier the tenant is on. It just calls primitives through naive.forUser(tenantId) and lets Naïve enforce the plan.
import { Naive, NaiveError } from "@usenaive-sdk/server";
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });
async function buildContentBrief(tenantId: string, keyword: string) {
const t = naive.forUser(tenantId);
// 1. SEO research — metered primitive `seo`.
const volume = await t.seo.searchVolume([keyword]);
const competitors = await t.seo.serpCompetitors([keyword]);
// 2. AI-search audit — metered primitive `aeo`.
const aiAnswer = await t.aeo.llmResponses("chatgpt", {
user_prompt: `best ${keyword} 2026`,
model_name: "gpt-4o-mini",
});
// 3. Compose a structured brief — llm (OpenRouter wrapper), billed in credits.
const brief = await t.llm.chat({
model: "anthropic/claude-sonnet-4.6",
messages: [
{
role: "system",
content: "You are an SEO strategist. Return a content brief as JSON.",
},
{
role: "user",
content: JSON.stringify({ keyword, volume, competitors, aiAnswer }),
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "content_brief",
strict: true,
schema: {
type: "object",
properties: {
title: { type: "string" },
target_keyword: { type: "string" },
search_volume: { type: "number" },
competing_domains: { type: "array", items: { type: "string" } },
ai_cited_domains: { type: "array", items: { type: "string" } },
outline: { type: "array", items: { type: "string" } },
},
required: [
"title", "target_keyword", "search_volume",
"competing_domains", "ai_cited_domains", "outline",
],
additionalProperties: false,
},
},
},
});
// response_format json_schema => content is a JSON string. Parse it.
const parsed = JSON.parse(brief.choices[0].message.content);
return { brief: parsed, cost: brief.credits_used };
}seo.searchVolume/seo.serpCompetitorsandaeo.llmResponses("chatgpt", { user_prompt, model_name })are the exact signatures from the SEO and AEO sub-clients.llm.chatforwards the body verbatim to OpenRouter. Themodelslug (anthropic/claude-sonnet-4.6) and theresponse_format: { type: "json_schema", ... }block are OpenRouter's own shapes — see thellmsub-client. With a strict JSON schema,message.contentcomes back as a JSON string, so youJSON.parseit.brief.credits_usedis what the OpenRouter call cost in Naïve credits — the number you price your LLM usage against.
Step 5: Watch the plan enforce itself
This is the payoff. The moat is not a claim in the marketing copy — it is two error codes your agent code never raises.
A free tenant hits a feature it never paid for
buildContentBrief calls aeo.llmResponses. The free tenant's kit has aeo disabled, so the call throws before it runs — no partial work, no AI-search credits spent.
try {
await buildContentBrief(freeTenant.id, "project management software");
} catch (err) {
if (err instanceof NaiveError && err.code === "forbidden") {
// err.status === 403, err.code === "forbidden" (primitive_disabled_by_kit)
// Prompt the customer to upgrade — the tier, not your code, said no.
console.log("aeo not on this plan:", err.hint);
}
}forbiddenwith reasonprimitive_disabled_by_kitis the documented code for calling a primitive the tenant's Account Kit disables (Errors).
A growth tenant burns through its quota
The growth tenant can call seo, but only 2000 times this month. On call 2001, Naïve returns 429.
try {
await naive.forUser(growthTenant.id).seo.searchVolume(["crm software"]);
} catch (err) {
if (err instanceof NaiveError && err.status === 429 && err.code === "rate_limited") {
// The 429 body carries the counters, e.g.
// { code: "rate_limited",
// message: "Plan quota exceeded for 'seo' (2000/2000 this period)",
// used: 2000, limit: 2000 }
console.log("seo quota exhausted — upsell or wait for period reset");
}
}429 rate_limitedon quota overrun is documented in Customer Billing; the response body reportsusedandlimit. You never wrote a counter — Naïve recorded one usage event per successfulseocall and rejected the one that crossed the line.
Read the meter any time
Render a usage bar in your dashboard straight from the rollup — no aggregation query on your side.
const usage = await naive.forUser(growthTenant.id).billing.usage();
// {
// plan: "growth", status: "active", period: "month",
// quotas: { seo: 2000, aeo: 500 },
// usage: { seo: 2000, aeo: 143 }
// }Step 6: Upgrades and downgrades from your Stripe webhook
The customer clicks Upgrade to Scale; Stripe fires customer.subscription.updated. Your webhook makes one call, and the tenant's capability and quota change on their next request.
// inside your POST /webhooks/stripe handler, after verifying the signature
export async function onStripeSubscriptionUpdated(event: StripeSubscriptionEvent) {
const tenantId = event.metadata.naive_user_id; // you stored this on subscribe
const planKey = event.metadata.plan_key; // "growth" | "scale" | "free"
await naive.forUser(tenantId).billing.setSubscription({
planKey,
status: "active",
stripeCustomerId: event.customer,
stripeSubscriptionId: event.subscription,
currentPeriodEnd: event.current_period_end, // ISO string
assignKit: true, // swap the Account Kit atomically with the subscription
});
}- Because enforcement is read at call time, the upgrade is live immediately: the previously-
forbiddenaeoandemailprimitives now succeed, and quotas jump to the Scale numbers. - A downgrade is the identical call with a smaller
planKey— it re-gates the primitives and lowers the ceilings. No migration, no flag flip, no cache to bust.
Deliver the brief on the tiers that include it
Now that a Scale tenant has email enabled, the final step of the job runs for them and stays forbidden for everyone else — same code.
async function deliverBrief(tenantId: string, to: string, brief: unknown) {
const t = naive.forUser(tenantId);
try {
const inbox = await t.email.createInbox({ local_part: "briefs" });
await t.email.send({
from_inbox: inbox.id,
to,
subject: "Your content brief is ready",
body: JSON.stringify(brief, null, 2),
});
} catch (err) {
if (err instanceof NaiveError && err.code === "forbidden") {
// email isn't on this tenant's tier — deliver in-app instead.
} else {
throw err;
}
}
}email.createInbox({ local_part })andemail.send({ from_inbox, to, subject, body })are the exactemailsub-client signatures, andemailis the third metered primitive — every send counts against the tenant'semailquota.
Why this needs the moat
Strip Naïve out and rebuild the same product on raw provider keys — a DataForSEO key, an LLM key, an email key — and the back office comes back:
- A usage table keyed by (customer, primitive, period), incremented on every call, read on every call.
- Feature flags per customer per feature, checked before each call and kept in sync with billing state.
- A race-free reject at the quota edge so two concurrent calls can't both slip past
1999 → 2001. - A migration every time a customer upgrades, to flip the flags and reset or reproject the counters.
That is the code you delete by using Customer Billing. Naïve makes plan → permissions → quota → usage a property of the tenant, enforced server-side, so:
- The agent job is a straight line — no tier branches, no counters.
- The tier is enforced at the primitive, so a bug in your loop can't over-serve a customer.
- Upgrades are one
setSubscriptioncall and take effect on the next request. - Each tenant's usage, quota, and capability are isolated by
forUser(id)— one code base, every customer governed independently.
You demonstrate the moat by trying to break it: the free tenant's aeo call returns forbidden, the growth tenant's 2001st seo call returns 429, and neither line of enforcement lives in your repo.
Get started
- Install the SDK —
npm install @usenaive-sdk/serverand setNAIVE_API_KEY. - Create one Account Kit per tier — enable/disable
seo,aeo,emailper the tier's features. - Upsert one plan per tier — map each
accountKitIdto itsquotasandperiod. - Subscribe tenants from your Stripe webhook —
setSubscription({ planKey, assignKit: true }). - Run the job with
forUser(tenantId)— letforbiddenand429 rate_limiteddo the enforcing.
- Customer Billing: usenaive.ai/docs/getting-started/customer-billing
- Account Kits: usenaive.ai/docs/getting-started/account-kits
- SEO primitive: usenaive.ai/docs/getting-started/seo
- GEO/AEO primitive: usenaive.ai/docs/getting-started/aeo
- LLM (OpenRouter wrapper): usenaive.ai/docs/getting-started/llm
- Full documentation: usenaive.ai/docs
What is a usage-metered SaaS and why is it hard to build?+
How does Naïve enforce a plan at execution time?+
Which primitives are metered against a plan quota?+
How do upgrades and downgrades work?+
Where does OpenRouter fit in?+
What does this cost to run?+
How do I get started?+
Building the autonomous company infrastructure.
Define plans that map to an Account Kit, a Stripe price, and usage quotas — then reflect each customer's subscription and read their metered usage. The monetization layer for building a multi-tenant product on Naïve.
Keyword volume, backlinks, SERP competitors, and ranked-keyword data for classic search — plus AEO/GEO visibility into whether ChatGPT, Claude, Gemini, and Perplexity actually mention you. The full visibility stack behind one key.
OpenAI-compatible chat completions across 300+ models with provider routing and fallbacks — streaming included, no per-provider keys, billed in Naïve credits at the exact upstream cost. Plus a drop-in OpenRouter proxy so your existing code just changes a base URL.
Human-in-the-loop approvals that freeze sensitive actions until you say yes, a per-user audit trail of everything an agent did, short-lived scoped MCP sessions, and unified async job tracking — the controls that make an autonomous fleet safe to run.