Guide15 min read

Build a Multi-Tenant AI Social Media Manager (Where the Plan Tier Decides — at Execution Time — Whether the Agent Can Publish)

Build a multi-tenant AI social media manager on Naïve where each brand's plan-tier Account Kit decides at execution time whether the agent may publish.

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

Per-brand architecture: each brand is a tenant user assigned to a plan-tier Account Kit; the agent is scoped with naive.forUser(brand) and writes copy with an OpenRouter model, then posts to that brand's own connected accounts — with the Draft tier blocked as forbidden and the Review tier frozen for approval.

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:

PrimitiveRole in the social agentDocs
UsersOne tenant user per brand — the isolation boundarynaive.users
Account KitsThe plan tier: enabled primitives + approval rulesnaive.accountKits
LLMOpenRouter-backed copywriting across 300+ modelsforUser(id).llm
SocialPer-brand posting to 10+ platformsforUser(id).social
ApprovalsHuman-in-the-loop on publishing (Review tier)forUser(id).approvals
LogsPer-brand audit trail of every agent actionforUser(id).logs
Customer BillingMap a plan to its kit and a Stripe pricenaive.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/server
import { 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 onboard

What each control does, straight from the docs:

  • primitives_config.social.enabled — toggles the whole social primitive. Disabled means any social call returns forbidden.
  • primitives_config.social.requiresApproval — when on, the agent's publish is frozen for a human to approve before it runs; set it false to opt out.
  • The kit is evaluated inside Naïve on every call — there is no application path that can "forget" to check the plan.

Capability tiers as Account Kits: the same agent code, governed by different kits. Draft disables social entirely; Review enables it with approval; Autopilot enables it outright. The API enforces the boundary at execution time.

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 connect endpoint returns an OAuth url; send the brand there (or use the multi-platform portal to connect several at once). After they authorize, call sync to 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 posts

Step 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.enabled on 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.
  • providerrouting preferences: sort by "price", deny providers that retain data, and require providers that support the parameters we send.
  • response_formatstructured 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 credits
  • model takes a provider-prefixed id — anthropic/claude-sonnet-4.6, openai/gpt-5.2, etc. Browse the live catalog for free with agent.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 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-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_approval and 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 });

The approval lifecycle on the Review tier: the agent's publish is frozen and returns 202 pending_approval with an approval_id; a human approves or denies; on approval the API replays the frozen post and it goes live.

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).social is 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 is pending_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.

Frequently Asked Questions
Why can't I just build a multi-tenant social scheduler with a model API and a few OAuth integrations?+
You can write the copy-generation loop in an afternoon. The hard part is identity and authority: each brand's agent must post inside that brand's own social accounts, which means storing and refreshing per-brand OAuth tokens for every platform, encrypting them, scoping every call so one brand's agent can never post to another's audience, and enforcing which plan tiers may actually publish — at the moment the agent acts, not as a toggle in your UI. Naïve ships those as primitives: tenant users, Account Kits, per-user social accounts, and an approvals queue. The model call is the easy 10%; the moat is the other 90%.
How does the plan tier actually decide whether the agent can publish?+
Each plan is an Account Kit — a reusable policy template that declares which native primitives are enabled and whether each requires human approval. The social primitive is enabled (or not) and approval-gated (or not) by the kit. A Draft customer whose kit disables social gets forbidden the instant their agent calls social; a Review customer whose kit sets social.requiresApproval gets 202 pending_approval; an Autopilot customer publishes immediately. You assign a brand to a kit, and every call through naive.forUser(brandId) is filtered by it server-side.
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 brand's Account Kit disables the social primitive, social.createPost returns forbidden. If the kit sets requiresApproval on social, the publish is frozen as 202 pending_approval until a human approves — at which point the API replays the exact frozen action. There's no window where an unauthorized post slips through, and changing the policy is a one-line Account Kit edit that applies to every brand on that tier.
How does the agent post from the brand's own accounts instead of a shared relay?+
Each brand connects their own social accounts once (X, LinkedIn, Instagram, TikTok, YouTube, and more) through Naïve's hosted connect flow. From then on, naive.forUser(brandId).social.createPost(...) publishes through that brand's authenticated accounts, so posts carry the brand's own identity, audience, and analytics — not a shared sender. The scoped client can only ever see and act on that one brand's accounts.
Which models can the 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 fallback chains (models) and provider routing (provider). 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), returned per call as credits_used.
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 every call per brand with naive.forUser(brandId). When a brand needs to connect a social account, you send them to Naïve's hosted social connect flow.
How do I upgrade or downgrade a brand's publishing capability?+
Reassign them to a different Account Kit with naive.accountKits.assignUser(kitId, brandId) (or naive.users.update(brandId, { account_kit_id })). Because every call is filtered by the kit at execution time, the new policy — Draft, Review, or Autopilot — takes effect on the very next call, with no redeploy and no per-brand config to keep in sync.
NT
Naïve Team

Building the autonomous company infrastructure.

Keep reading