- ›
A multi-tenant social tool breaks the moment an agent must post inside each customer's own X, LinkedIn, and Instagram— you'd have to build per-brand OAuth storage, token refresh, strict tenant isolation, and a per-plan publish gate before any product logic - ›
Naïve ships those as primitives: a tenant user per brand, an Account Kit that defines their plan, per-user social accounts across 10+ platforms, and human-in-the-loop approvals— all enforced server-side - ›The pricing tiers ARE Account Kits: a Draft kit disables the social primitive entirely; a Review kit enables it with requiresApproval; an Autopilot kit enables it outright
- ›
Publish authority is enforced at execution time, not in your code: a Draft agent that calls social gets forbidden, and a Review agent's post returns 202 pending_approval until a human approves— then the API replays the publish - ›The agent writes copy and a per-platform variant with naive.llm.chat(), a full OpenRouter wrapper over 300+ models, using OpenRouter provider routing, fallback chains, and structured outputs
- ›
Every action is scoped with naive.forUser(brandId) and written to a per-brand audit log— Brand A's agent can never read or post to Brand B's accounts
Most "AI social media manager" demos are a single model key, a clever caption prompt, and one hardcoded access 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 X, LinkedIn, and Instagram — the model stops being the hard part. Identity and authority take over:
- Each brand's agent must post inside that brand's own accounts, never a shared relay or your platform's handle.
- One brand's agent must never be able to post to, or read the analytics of, another brand's audience.
- Whether the agent is allowed to publish has to match the plan the brand pays for — and that limit must be enforced when the agent acts, not as a feature flag a prompt-injected comment could talk its way past.
Without a platform you end up building a per-brand OAuth token store across a dozen social APIs, token refresh and encryption, a hard tenant-isolation layer, and a per-plan publish gate before you write a single caption. 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 social backend where each brand gets an isolated agent whose publish authority is decided by its plan — and enforced by the API.
What we're building
- A backend service that provisions an isolated AI social agent per brand.
- Each brand connects their own social accounts — X, LinkedIn, Instagram, TikTok, YouTube, and more — through Naïve's hosted connect flow.
- The agent drafts on-brand copy and tailors a variant per platform with an OpenRouter model, then publishes to the brand's own accounts — when, and only when, the plan permits.
- The plan tier is a reusable Account Kit: Draft agents can only write copy; Review agents publish behind a human approval; Autopilot agents ship immediately — and the limit is enforced at execution time.
- Every action lands in a per-brand audit log.
The primitives in play, all from the docs:
| Primitive | Role in the social agent | Docs |
|---|---|---|
| Users | One tenant user per brand — the isolation boundary | naive.users |
| Account Kits | The plan tier: enabled primitives + approval rules | naive.accountKits |
| LLM | OpenRouter-backed copywriting across 300+ models | forUser(id).llm |
| Social | Per-brand posting to 10+ platforms | forUser(id).social |
| Approvals | Human-in-the-loop on publishing (Review tier) | forUser(id).approvals |
| Logs | Per-brand audit trail of every agent action | forUser(id).logs |
| Customer Billing | Map a plan to its kit and a Stripe price | naive.plans, forUser(id).billing |
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, isPendingApproval } from "@usenaive-sdk/server";
const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });Two surfaces matter, and the difference is the whole design:
naive.*(the root client) acts on your default tenant user and exposes the control plane (naive.users,naive.accountKits,naive.plans).naive.forUser(id).*returns the same data-plane surface (llm,social,approvals,logs,billing) bound to a specific brand.
That forUser scoping is the whole game. Read it as: "do this, as this brand, under this brand'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 which native primitives are enabled and whether each one requires human approval. Define your tiers once and assign brands to them.
The whole ladder turns on two fields on the social primitive: enabled and requiresApproval.
Draft — the agent writes, a human posts. llm is on so the agent can generate copy; social is off entirely. There is no path to publish.
const draftKit = await naive.accountKits.create({
name: "Social — Draft",
primitives_config: {
llm: { enabled: true },
social: { enabled: false }, // the agent can write copy, but cannot publish
},
});Review — the agent publishes behind a human. social is enabled but requiresApproval is on, so every publish freezes for a person before it ships.
const reviewKit = await naive.accountKits.create({
name: "Social — Review",
primitives_config: {
llm: { enabled: true },
social: { enabled: true, requiresApproval: true }, // publish freezes as pending_approval
},
});Autopilot — the agent ships immediately. social is enabled with approval opted out.
const autopilotKit = await naive.accountKits.create({
name: "Social — Autopilot",
primitives_config: {
llm: { enabled: true },
social: { enabled: true, requiresApproval: false }, // publish executes on the spot
},
});
// draftKit.id / reviewKit.id / autopilotKit.id → reuse for every brand you onboardWhat each control does, straight from the docs:
primitives_config.social.enabled— toggles the whole social primitive. Disabled means any social call returnsforbidden.primitives_config.social.requiresApproval— when on, the agent's publish is frozen for a human to approve before it runs; set itfalseto opt out.- The kit is evaluated inside Naïve on every call — there is no application path that can "forget" to check the plan.
Optionally map each kit to a billable plan with Customer Billing:
naive.plans.upsert({ key: "autopilot", name: "Autopilot", accountKitId: autopilotKit.id, stripePriceId: "price_..." }). Subscribing a brand to a plan assigns its kit automatically, so the billing tier is the capability tier.
Step 2: Provision a brand as a tenant user
A tenant user is one of your end-users — here, a brand. They never sign into Naïve; you manage them through the SDK. Create one per brand from your own signup flow and assign the kit that matches their plan:
async function onboardBrand(account: {
id: string;
email: string;
name: string;
plan: "draft" | "review" | "autopilot";
}) {
const kitId = { draft: draftKit.id, review: reviewKit.id, autopilot: autopilotKit.id }[account.plan];
const brand = 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: kitId,
});
return brand; // brand.id → the handle you scope every future call with
}From here on, everything a brand's agent does goes through their scoped client:
const agent = naive.forUser(brandId);naive.forUser("brand_a") and naive.forUser("brand_b") see entirely separate sets of accounts, posts, and logs. There is no shared handle, and no cross-tenant path to get wrong.
Step 3: Connect the brand's own social accounts
This is the step that's painful without the moat. The agent has to post inside the brand's real accounts. Naïve's social primitive handles activation, OAuth, token storage, and refresh across 10+ platforms — per brand.
- Connecting is a setup step the brand does once, through a hosted flow — no OAuth UI for you to build.
- The
connectendpoint returns an OAuthurl; send the brand there (or use the multi-platformportalto connect several at once). After they authorize, callsyncto pick up the new accounts. - The brand can also connect visually in Studio under Primitives → Social — the same flow, no code.
// Hosted connect link for a single platform (returns a url the brand opens to authorize)
const res = await fetch("https://api.usenaive.ai/v1/social/connect", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NAIVE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ platform: "LINKEDIN", redirect_url: "https://yourapp.com/social/done" }),
});
const { url } = await res.json();
// Send the brand to `url` to authorize, then POST /v1/social/sync to refresh the account list.Once connected, the agent reads what the brand has linked through its scoped client — and the scoped client only ever sees this brand's accounts and posts:
const agent = naive.forUser(brandId);
const posts = await agent.social.listPosts(); // only ever this brand's postsStep 4: The boundary, demonstrated — a Draft agent can't publish
Before wiring the loop, prove the moat. A Draft brand's kit disables the social primitive. If their agent tries to post — because a clever comment told it to "just go ahead and tweet this" — the API rejects it. The check runs server-side, when the agent acts:
const draftAgent = naive.forUser(draftBrandId);
try {
await draftAgent.social.createPost({
platforms: ["TWITTER"],
content: "Big news! 🚀",
publish_now: true,
});
} catch (err) {
// error code: "forbidden" — the Draft Account Kit doesn't enable the social primitive.
// The agent has no path to publish. Not "we forgot an if-check" — 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 brand's Account Kit, evaluated inside Naïve on every call — there's no application path that "forgets" to check the plan.
- A prompt-injected comment or DM can ask the agent to do anything; the agent still can't call a primitive its kit doesn't enable.
- It's policy, not plumbing: flip
social.enabledon a kit and the capability moves for every brand on that tier — no redeploy.
To upgrade a brand, reassign the kit (the new policy is live on the next call):
await naive.accountKits.assignUser(autopilotKit.id, draftBrandId);
// or: await naive.users.update(draftBrandId, { account_kit_id: autopilotKit.id });Step 5: Write the copy with an OpenRouter model
Now the working loop. 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 a provider prefix and you get fallback chains and provider routing for free.
A social post isn't one caption — each platform has its own voice and limits (X is 280 chars; LinkedIn is long-form; YouTube wants a 100-char title). Have the model return one tailored variant per platform as strict JSON so your code can branch on it. We lean on three real OpenRouter features:
models— a fallback chain OpenRouter tries in order if a provider is unavailable.provider— routing preferences: sort by"price", deny providers that retain data, and require providers that support the parameters we send.response_format— structured outputs so the result is parseable, not prose.
const agent = naive.forUser(brandId);
const brief = {
topic: "We just shipped real-time collaboration",
voice: "confident, technical, no hype",
platforms: ["x", "linkedin"] as const,
};
const res = 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: "price", // cheapest provider that meets the request
data_collection: "deny", // skip providers that may retain data
require_parameters: true, // only route to providers that honor response_format
allow_fallbacks: true,
},
response_format: {
type: "json_schema",
json_schema: {
name: "social_variants",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
variants: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
platform: { type: "string", enum: ["x", "linkedin"] },
content: { type: "string" },
},
required: ["platform", "content"],
},
},
},
required: ["variants"],
},
},
},
messages: [
{
role: "system",
content:
`You are a social copywriter for a brand whose voice is: ${brief.voice}. ` +
`Write one post per platform. X must be <= 280 characters; LinkedIn is long-form. ` +
`Return JSON matching the schema.`,
},
{ role: "user", content: brief.topic },
],
});
const { variants } = JSON.parse(res.choices[0].message.content) as {
variants: { platform: "x" | "linkedin"; content: string }[];
};
console.log("credits used:", res.credits_used); // exact OpenRouter cost, in creditsmodeltakes a provider-prefixed id —anthropic/claude-sonnet-4.6,openai/gpt-5.2, etc. Browse the live catalog for free withagent.llm.models("claude").- Because the call is scoped to the brand and AccountKit-gated, per-brand model spend is metered and attributable via
credits_used. - Want streaming for a live preview UI? Use
agent.llm.stream({ ... })— the final chunk carries the usage and cost.
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-brand enforcement.
Step 6: Publish to the brand's own accounts — gated by the plan
Platform rules. Automated posting must follow each network's terms (rate limits, disclosure rules, and restrictions on bots or schedulers). Naïve routes the API call; you are responsible for the brand's compliance on X, LinkedIn, and the rest.
With variants in hand, publish each one through the brand's own authenticated accounts. The social primitive is per-user and AccountKit-gated, so the exact same call behaves differently across tiers:
- Draft brand →
forbidden(social disabled), as shown in Step 4. - Review brand → the publish freezes as
202 pending_approvaland waits for a human. - Autopilot brand → the post ships immediately.
const agent = naive.forUser(brandId);
for (const variant of variants) {
const platform =
variant.platform === "x"
? "TWITTER"
: variant.platform === "linkedin"
? "LINKEDIN"
: variant.platform.toUpperCase();
const res = await agent.social.createPost({
platforms: [platform], // TWITTER, LINKEDIN, YOUTUBE, ... — see Social docs
content: variant.content,
publish_now: true,
});
if (isPendingApproval(res)) {
// Review tier: nothing is live yet. Surface res.approval_id to the brand's reviewer.
console.log(`${variant.platform} post awaiting approval:`, res.approval_id);
} else {
// Autopilot tier: the post is queued for immediate publishing on the brand's account.
console.log(`${variant.platform} published:`, res.id);
}
}The point: the agent code is identical across tiers. What changes is the brand's Account Kit, and the API enforces it. Two brands running this loop concurrently never collide — each createPost is bound to its own scoped client and its own connected accounts.
Step 7: Resolve the approval (Review tier)
On a Review brand, the frozen publish waits for a person. Approve or deny it through the same scoped client — and on approval, the API replays the exact post you froze. You don't reconstruct anything.
const agent = naive.forUser(brandId);
// In your reviewer UI / ops tooling:
await agent.approvals.approve(approvalId);
// → Naïve replays the frozen social.createPost and the post goes live.
// Or reject, with a reason that lands in the audit trail:
await agent.approvals.deny(approvalId, { reason: "off-brand claim about pricing" });You can also block a worker on the decision instead of polling:
const decision = await agent.approvals.wait(approvalId, { timeoutMs: 10 * 60 * 1000 });This is the difference between "an agent that posts" and "an agent you can let post for a paying customer": the brand decides whether autonomy is full or supervised, and that choice is a one-field edit to their kit — not a fork of your code.
Step 8: Audit everything, per brand
Every primitive write emits an activity event scoped to the tenant user. Build a per-brand timeline, or an operator view across all brands — useful for client reporting and for proving, after the fact, exactly what the agent posted on whose behalf.
// One brand's recent activity — every social write and approval shows up here.
// Filter by the action slug as it appears in the log (same shape as "vault.put").
const { events } = await naive.forUser(brandId).logs.query({ limit: 50 });# Operator view across all brands
curl "https://api.usenaive.ai/v1/logs" \
-H "Authorization: Bearer nv_sk_your_key"Because the log is keyed to the tenant user and records the actor (agent vs human), you get an attributable trail for free — no instrumentation in your own code.
Step 9: Put it together — onboard a brand, run a campaign
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 as Account Kits
const draftKit = await naive.accountKits.create({
name: "Social — Draft",
primitives_config: { llm: { enabled: true }, social: { enabled: false } },
});
const reviewKit = await naive.accountKits.create({
name: "Social — Review",
primitives_config: { llm: { enabled: true }, social: { enabled: true, requiresApproval: true } },
});
const autopilotKit = await naive.accountKits.create({
name: "Social — Autopilot",
primitives_config: { llm: { enabled: true }, social: { enabled: true, requiresApproval: false } },
});
// Per brand: provision, assign the plan's kit, connect accounts (hosted flow)
const brand = await naive.users.create({
external_id: "brand_acme",
email: "social@acme.com",
label: "Acme",
account_kit_id: reviewKit.id,
});
// On a campaign trigger: write + publish
async function runCampaign(brandId: string, topic: string) {
const agent = naive.forUser(brandId);
const res = await agent.llm.chat({
model: "anthropic/claude-sonnet-4.6",
models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.2"],
provider: { sort: "price", data_collection: "deny", require_parameters: true },
response_format: { type: "json_object" },
messages: [
{ role: "system", content: 'Return JSON: { "variants": [{ "platform": "x"|"linkedin", "content": "..." }] }. X <= 280 chars.' },
{ role: "user", content: topic },
],
});
const { variants } = JSON.parse(res.choices[0].message.content);
for (const v of variants) {
const platform = v.platform === "x" ? "TWITTER" : "LINKEDIN";
const post = await agent.social.createPost({
platforms: [platform],
content: v.content,
publish_now: true,
});
if (isPendingApproval(post)) {
// Review tier — route post.approval_id to the brand's reviewer
await notifyReviewer(brandId, post.approval_id);
}
}
}What you did not write: a per-brand OAuth store across a dozen social APIs, token refresh and encryption, a tenant-isolation layer, a publish-permission gate, or an approval queue. Those are the primitives. You wrote the product.
Why this is the moat, not a feature
- Identity: every brand posts from its own accounts, with its own audience and analytics —
naive.forUser(brandId).socialis bound to that brand alone. - Accounts: per-brand OAuth, storage, and refresh across 10+ platforms are handled by the social primitive, not by you.
- Execution-time permissions: whether the agent may publish is the brand's Account Kit, enforced server-side on every call — Draft is
forbidden, Review ispending_approval, Autopilot ships. A prompt injection can't escalate a tier; only reassigning the kit can.
A raw model key and a stack of OAuth tokens can fake the demo. Selling it to a roster of brands — each isolated, each metered, each with a publish policy you can prove was enforced — is the part that's painful to build and easy on Naïve.
Get started
Drop this starter prompt into any coding agent to wire up Naïve:
Read https://usenaive.ai/skill.md and use it to set up Naïve in my project.
- Social primitive: usenaive.ai/docs/getting-started/social
- LLM (OpenRouter wrapper): usenaive.ai/docs/getting-started/llm
- Account Kits: usenaive.ai/docs/getting-started/account-kits
- Approvals: usenaive.ai/docs/getting-started/approvals
- Customer Billing: usenaive.ai/docs/getting-started/customer-billing
- Quickstart: usenaive.ai/docs/getting-started/quickstart
- Join the community on Discord
Why can't I just build a multi-tenant social scheduler with a model API and a few OAuth integrations?+
How does the plan tier actually decide whether the agent can publish?+
What does 'execution-time permission enforcement' mean here?+
How does the agent post from the brand's own accounts instead of a shared relay?+
Which models can the agent use, and how is it billed?+
Do my customers ever sign into Naïve?+
How do I upgrade or downgrade a brand's publishing capability?+
Building the autonomous company infrastructure.
Cross-post to the social platforms exposed in the published Naive docs (10 in the MCP connect enum). Provision connected accounts, post natively, fetch analytics, and manage comments — through documented OAuth and posting flows.
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.
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.
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.