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 carriesis_gatedplus 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.yamldocuments 19 operations. The SDK exposes a few more methods than that —getNav,listPages,getEventBySlugand 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
recordVisitto attribute correctly, passgetHost,getIp,getUserAgent, andgetRefererresolvers. Server-side, readx-forwarded-host/x-forwarded-forfirst (Vercel, Cloudflare, Amplify all set them). Without the host resolver, visits can be dropped or attributed to the gateway's own host.recordVisitandrecordCtaClickswallow 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_reporequires the repo to be pushed to the nukipa-labs GitHub org (the tool'srepo_urlarg states this). The downstream deployer that consumes the recorded URL is not wired yet — for now the agent owns repo creation (viagh 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 atopic(cms,signals,crm,workflows, …) for a focused slice.list_workspaces— returns the workspaces this connection may operate in. This is how the agent discovers theworkspace_idvalues it can pass.
[!TIP] If you're working out what an agent can do, point it at
nukipa_guidefirst. 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).
- 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. - 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 sessionafter 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, orpublish.request_changesreturns the change text (optionally anchored to a specific section heading) for the agent to apply and re-submit;approve/publishtells 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_activewhen 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.