Steeren/Operating model & accesslive from the platform← site
Understand Nukipa

Operating model & access

Nukipa runs behind your website: the content, leads, CRM, newsletters, and analytics live in Nukipa, and your site reads from it through a public API. You operate the platform through three surfaces — a dashboard for people, a public API for your site, and an MCP tool surface that an AI agent drives. This page covers those three surfaces, how access is scoped (nk_ keys and a required workspace_id on every call), what runs at request time versus build time, and where a human sits in the loop before something goes live.

The three surfaces

Surface Who uses it Auth What it's for
Dashboard People (your team) Supabase login (JWT) Reviewing, editing, approving, reading analytics, managing API keys
Public API (/public/v1/*) Your website None (host-resolved, published content only) Fetching posts/pages, submitting forms, recording visits and CTA clicks
MCP tools An AI agent (Claude, ChatGPT, …) nk_ API key or your login Driving the platform: writing posts, running campaigns, managing the CRM, etc.

These are three doors into the same data. The dashboard and the agent both go through the gateway; the public API is the read-and-submit surface your site calls.

The dashboard

The dashboard is the human surface. You log in (Supabase auth), and your login resolves to the set of workspaces you're a member of. From there you review drafts, edit, approve and publish, browse analytics, manage the CRM, and create the API keys an agent uses.

When you act in the dashboard you're authenticated with a JWT. The gateway looks up your tenant_members rows and lets you operate in any workspace you're currently a member of — there's no separate per-key grant to manage for your own account.

The public API

Your site reads content and sends signals back through the public API at /public/v1/*. This surface needs no key. It resolves the tenant from the incoming host header (X-Forwarded-Host), and it only ever serves published content — a draft is invisible to the public API until it's published.

[!NOTE] "Published" doesn't always mean "the full body". A gated post is published, but the public API returns only a teaser — the excerpt plus the first N paragraphs (gate_after_paragraph, default 2) — until the reader submits the gate form. The response carries is_gated plus the gate form's fields so your site can render the form. See content gating for how that's set up.

The simplest way to consume it is the @nukipa/site-sdk client, a thin typed wrapper over these endpoints:

import { createNukipaClient } from '@nukipa/site-sdk';
import { headers } from 'next/headers';

const client = createNukipaClient({
  gatewayUrl: process.env.NUKIPA_GATEWAY_URL!,
  getHost: async () =>
    process.env.NUKIPA_TENANT_HOST
    || (await headers()).get('x-forwarded-host')
    || (await headers()).get('host')
    || '',
});

const tenant = await client.getTenant();
const posts  = await client.listPosts({ limit: 10 });
await client.recordVisit({ path: '/blog' });

The read methods (getTenant, listPosts, getPostBySlug, listFolders, listPages, getEventBySlug, getNav, …) return published content. The write methods are scoped to visitor-safe actions: submitForm, subscribeNewsletter, runAudit, recordVisit, recordCtaClick. There's no way to publish or edit content through the public API — that's deliberate. Authoring goes through the agent or the dashboard.

[!NOTE] The public API is small on purpose: it's the read side of your site plus the form/visit/subscribe/audit endpoints, not a general-purpose CMS API. The OpenAPI spec at apps/gateway/openapi.public.yaml documents 19 operations. The SDK exposes a few more methods than that — getNav, listPages, getEventBySlug and the rest of the headless-CMS reads hit live endpoints that aren't yet in the spec (their types are loosely declared in the SDK until the spec grows to cover them). So treat the SDK's method list — not the spec — as the source of truth for what's callable.

Build-time vs runtime (Next.js)

The SDK's caching is built on Next.js App Router primitives. On a Next.js consumer you get two modes per call; on any other target (a Nuxt surface, a static export, a plain Node consumer) the next.revalidate hint is inert and every read is just an uncached fetch.

Cached / revalidated reads. On Next.js, content GETs default to Incremental Static Regeneration: the SDK sets next.revalidate (60 seconds by default). So listPosts / getPostBySlug render statically and refresh on a timer — a freshly published post appears on the next revalidation, not necessarily the same instant. Change the window with defaultRevalidate, or pass false to disable caching for a given read.

Uncached reads and all writes. Anything that must reflect the live visitor or the live database is sent with cache: 'no-store'. Form submissions, newsletter subscribes, visit pings, CTA clicks, and audit-run polling all run at request time, every time.

Path When it runs Caching
listPosts, getPostBySlug, listPages, getNav, … Build / revalidate (Next.js); plain fetch elsewhere next.revalidate (default 60s)
submitForm, subscribeNewsletter, runAudit, submitAuditGate Request time no-store
recordVisit, recordCtaClick Request time no-store, errors swallowed
getAuditRun (polling) Request time no-store

[!TIP] For recordVisit to attribute correctly, pass getHost, getIp, getUserAgent, and getReferer resolvers. Server-side, read x-forwarded-host / x-forwarded-for first (Vercel, Cloudflare, Amplify all set them). Without the host resolver, visits can be dropped or attributed to the gateway's own host. recordVisit and recordCtaClick swallow their own errors, so a failed ping never breaks a page render.

Where your site lives is up to you: host it yourself, or push it to a GitHub repo Nukipa records on the tenant. The agent can record that repo via nukipa_connect_github_repo — it stores the URL on the tenant row.

[!WARNING] nukipa_connect_github_repo requires the repo to be pushed to the nukipa-labs GitHub org (the tool's repo_url arg states this). The downstream deployer that consumes the recorded URL is not wired yet — for now the agent owns repo creation (via gh repo create) and you own hosting.

The MCP tool surface

The agent drives Nukipa through MCP (Model Context Protocol). The gateway exposes one MCP endpoint that registers the whole tool surface — CMS, context/knowledge base, signals/analytics, CRM, newsletters, nurturing, social, ingestion, audits — plus a couple of meta tools. You connect an MCP client (Claude.ai, ChatGPT, Claude Code, etc.) to that endpoint, authenticate, and the agent gets the tools.

Tool names are namespaced by service: cms_create_post, cms_publish_post, crm_list_contacts, signals_gsc_summary, newsletters_send_issue, and so on. The agent also has two meta tools that are not scoped to a workspace:

  • nukipa_guide — returns the platform guide (service map, typical workflows, key concepts). The agent calls this first on a new task. Pass a topic (cms, signals, crm, workflows, …) for a focused slice.
  • list_workspaces — returns the workspaces this connection may operate in. This is how the agent discovers the workspace_id values it can pass.

[!TIP] If you're working out what an agent can do, point it at nukipa_guide first. It's the same static guide the platform ships to every agent, and it lists the canonical workflows (writing a post, running a campaign, a newsletter issue, lead capture).

Two ways to authenticate

There are two ways an MCP connection authenticates, and both resolve to the same thing: a set of workspaces this connection may touch (its allowedTenantIds).

  1. An nk_ API key (Authorization: Bearer nk_…). You mint these in the dashboard. The key's allowed-workspace set is the upper bound on what it can reach.
  2. Your login (JWT) — used by the dashboard and direct API consumers. A JWT can operate in any workspace you're currently a member of.

For spec-compliant clients (Claude.ai, ChatGPT) there's also an OAuth path that mints an nk_ token for you. When you add Nukipa as a connector, the client discovers the OAuth server automatically (the gateway answers a 401 with a WWW-Authenticate pointer to its metadata) and walks you through a consent screen. There you authorize one or more workspaces; the resulting token's allowed set is exactly the workspaces you approved. OAuth-issued tokens last 24 hours and carry a 90-day refresh token; personal keys you create in the dashboard don't expire until you revoke them.

Authorization: Bearer nk_<40 hex chars>
Personal key OAuth token
Created from Dashboard ("create API key") Connector consent flow
Scope The single active workspace it was made in The one-or-more workspaces you approve at consent
Expiry None (until revoked) 24h access + 90d refresh
Stored as sha256 hash only — raw key shown once same

[!NOTE] A dashboard-created personal key is scoped to a single workspace — the active one you created it in. There's no dashboard path to mint a multi-workspace personal key. A token that spans several workspaces comes from OAuth consent (where you approve more than one) — or from externally provisioned / legacy multi-tenant keys.

[!WARNING] A raw nk_ key is shown exactly once, at creation. Nukipa stores only the sha256 hash and cannot show you the key again. If you lose it, revoke it and make a new one. Revoking a key is owner-only — only the user who created a key can revoke it — and takes effect on the next call that validates the bearer (revoked keys are rejected at validation).

Every call takes workspace_id

There is no "active workspace" on an MCP connection. The session carries identity and the allowed-workspace set, and nothing else about which tenant you're operating on. Every domain tool — every one — takes a required workspace_id argument and runs against exactly that workspace for that one call.

// Discover the ids this connection can use:
list_workspaces()
// → { "workspaces": [{ "id": "a1b2…", "slug": "acme", "name": "Acme" }, …] }

// Then pass workspace_id on every domain call:
cms_create_post({
  workspace_id: "a1b2c3d4-…",   // required, always
  title: "Launch announcement",
  slug:  "launch",
  language: "en"
})

This is the same model the Supabase MCP server uses with project_id per call. It has two practical consequences:

  • One connection can drive several workspaces at once. Because no tenant state is shared between calls, two concurrent calls carrying different workspace_ids never race. An agent (or a key scoped to multiple workspaces) can work across them in parallel.
  • Authorization is checked per call, not just at connect. On each call the gateway checks (a) that the id is in the token's allowed set, and (b) — via a short-TTL (60s) membership re-check — that you're still a current member of that workspace.

The authorization checks are not symmetric, and it's worth being precise about why:

  • Revoked membership propagates (within ~60s). Tokens outlive memberships. The 60-second membership cache (MEMBERSHIP_TTL_MS) means that if your access to a workspace is removed, in-flight tokens stop working against it within about a minute — even though the token still lists it.
  • Newly granted access does not propagate mid-session. An nk_ token's allowed set is fixed when the connection authenticates. Being added to a new workspace, or having a workspace added to a token's allowed set, won't take effect on a live connection — you must re-authenticate (reconnect / re-authorize via OAuth) to pick it up.

If you pass a workspace_id outside the token's allowed set, the tool throws workspace_not_allowed with a message telling you to call list_workspaces or re-authorize via OAuth. If the id is allowed but you're no longer a member, it throws workspace_forbidden — "not a current member".

[!NOTE] A multi-workspace key will not guess a default. An early bug silently defaulted to a key's "primary" workspace and wrote a batch of ingested data — 8 RSS sources and 177 docs — to the wrong tenant. The fix was to refuse to guess: name the workspace explicitly on every call, or the call fails.

Other things the gateway can return

Beyond the two workspace errors above, a connection can hit a few non-happy-path states. Worth knowing so they don't look like outages:

Condition What happens
Too many failed auth attempts from one IP (>10 in 15 min) 429 too_many — the IP is blocked from authenticating until the window resets
Unknown or expired Mcp-Session-Id 404 unknown or expired session — re-initialize the connection
Idle session (no requests for 30 min) The session is evicted; the next call gets the same 404 → re-initialize
Invalid / revoked / expired bearer 401 with a WWW-Authenticate metadata pointer; reconnect to re-authenticate

[!TIP] A 404 unknown or expired session after a quiet period is normal — sessions idle-evict after 30 minutes. MCP clients re-initialize on a 404, so this is usually invisible; if you're driving the endpoint by hand, treat 404 as "start a new session", not "the server is down".

Approval before publish

Nothing the agent does is hidden, and the steps that put content in front of the public are explicit, separate tool calls — not side effects of drafting.

For status, a post moves through idea → draft → review → archived, with publishing as its own action: cms_publish_post (and cms_schedule_post / cms_unpublish_post). Status moves via cms_update_post are limited to that idea/draft/review/archived set; going live is never a status edit. Writing a body, generating a cover, verifying facts — none of those publish anything. An agent can produce a complete post and it stays a draft until publish is called. So the simplest approval model is: let the agent draft, then you publish from the dashboard (or you tell the agent to publish, having reviewed it).

When you publish, the platform snapshots the post into an append-only version history (post_versions) and the public site picks it up immediately on the backend — though a statically-rendered site still surfaces it on its next revalidation (see build-time vs runtime). Re-publishing appends a new version row; versions are never overwritten. Unpublishing flips the post back to draft and the public site stops serving it; the version history stays intact.

The Verify review loop

For a tighter, in-the-moment review there's Verify — a live view in the dashboard that shows what the agent is doing call-by-call and lets you leave feedback the agent acts on without you switching tools.

  • Every domain tool call is logged so the Verify view can show it as it happens. Each call can carry an intent — a short phrase ("Draft a launch announcement") that the agent reuses across related calls so they group as one step.
  • After the agent produces something reviewable (a post, a newsletter issue, a nurturing sequence, a context doc), it calls await_feedback. That's a long-poll: the call parks (default 25s per call, max 50s, then the agent re-calls to keep waiting) until you submit feedback in Verify, then returns it to the agent.
  • Your feedback has a kind: comment, request_changes, approve, or publish. request_changes returns the change text (optionally anchored to a specific section heading) for the agent to apply and re-submit; approve / publish tells the agent it's accepted and to stop.

So Verify is a human-in-the-loop gate: the agent waits on you, you review the actual artifact, and only when you approve does it move on. This is the path when you want to ride along; plain draft-then-publish is the path when you don't.

[!NOTE] Verify is opt-in per session — the agent only waits when a Verify view is actually open (tool results carry _meta.nukipa_verify_active when one is). With no Verify view open, the agent works straight through to a draft, and you review in the dashboard on your own time.

FAQ

Do I need an API key to put Nukipa behind my site? No. The public API (/public/v1/*) needs no key — it resolves your tenant from the request host and serves published content. You only need an nk_ key to let an agent operate the platform (authoring, CRM, newsletters, etc.).

Can one key operate multiple workspaces? It depends how the key was made. A personal key created in the dashboard is scoped to the single workspace you created it in. An OAuth token can span multiple workspaces — those are exactly the ones you approve at the consent screen. Either way, the connection's allowed set is the upper bound, and every call must still name a workspace_id within it.

I was just added to a workspace but my agent can't see it. Why? An nk_ token's allowed set is fixed when the connection authenticates. Newly granted access doesn't propagate to a live connection — reconnect (or re-authorize via OAuth) to pick up the new workspace. Removal is the opposite: it propagates within ~60 seconds without reconnecting.

What happens to in-flight tokens when someone leaves a workspace? They stop working against that workspace within roughly a minute. The token's allowed set is the upper bound, but each call also re-checks live membership against a 60-second cache, so a removed member can't keep acting on a long-lived token.

Does publishing happen instantly on my site? The backend marks it published immediately and the public API serves it right away. A statically-rendered Next.js site still refreshes on its revalidation timer (default 60s). Set defaultRevalidate (or pass false) on the SDK client to tune that. Non-Next.js consumers fetch uncached, so they see it on the next request.

Can the agent publish without me? It can call cms_publish_post if you let it. For a hard human gate, keep publishing to the dashboard, or use the Verify loop so the agent waits on your approve / publish before going live. Drafting never publishes on its own.

Where do I create and revoke keys? In the dashboard. Keys are listed per workspace, the raw value is shown once at creation, and only the key's owner can revoke it. Revoking takes effect on the next call that validates the key.

Served live from the platform · /docs/operating-model-and-access