Plugin & App Standard
There is one way to build a plugin, and every plugin uses it. A plugin is a marketplace app: you host it, Tanqory frames it, and the platform gives it a token scoped to exactly what the merchant granted. "First-party" (an app Tanqory itself ships) is a flag on the registry entry — not a second, looser way of building. This page is the normative checklist; the Quickstart and Authentication guides in this section are the how-to.
If you are starting a new plugin, scaffold it and skip to the checklist:
npx create-tanqory-app my-plugin
That template already wires everything below correctly. The rules exist so a hand-built or older plugin can be measured against the same bar.
The model in one paragraph
Your plugin is a web app served from your own origin. Tanqory renders it in an iframe inside the merchant dashboard, hands it a single-use boot code it exchanges for a token, and proxies its API calls under a token capped to the permissions the merchant granted. The install grant lives in Tanqory's registry; uninstalling revokes the token within its lifetime. Your plugin never sees a cell hostname, never holds a provider API key, and never calls a Tanqory API directly from the browser.
Conformance checklist
A plugin conforms when all five hold. create-tanqory-app +
@tanqory/plugin-sdk give
you (a)–(d) for free; (e) is your app code.
(a) Bridge
- Talk to the host through
@tanqory/plugin-sdk(which wraps@tanqory/app-bridge). Do not hand-roll postMessage. - Boot from the single-use code in your URL; exchange it for a token. Keep the token in memory only.
- Resolve the host origin and refuse messages from any other origin. Never
postMessage('*'). - On a dead session, emit
AUTH_EXPIREDto the host — never navigate the iframe to a login page. - Use one long-lived iframe: a single catch-all route
(
/embed/[storeId]/[[...slug]]),storeIdfrom the path, internal navigation reported withROUTE_CHANGED.
(b) Auth & token — capped, never the full session
- Get tokens only via the bridge's
requestToken(); make commerce calls only viaapiRequest()(path-only — the host prefixes the store and signs with an app token capped tograntedScopes). - Never obtain or hold the full merchant session token. The boot audience binds where a code may be redeemed; it does not scope a token. An app that holds the session token is account-takeover-grade trust and is not conformant — even for first-party apps (see below).
- The install grant is the authorization. No
ACTIVEinstallation → no token. That is what makes Uninstall a real kill switch.
(c) AI metering
If your plugin uses a model or embedding:
- Frontends never call
ai-apior a provider directly. Route every call throughstore-api, which forwards to the gateway (POST /api/gateway/completions). - The gateway bills by
x-tanqory-calleragainst a feature type. The cheapest conformant path is to reuse an existing feature under thestore-apicaller (e.g.EMAIL,CONTENT,IMAGE) — zeroai-apiedits. Billing under a new caller or feature is a coupled change across five places (see Registering a new AI feature) and needs its own review. - Always send a deterministic
idempotencyKey. admin-api dedups the deduct on a permanent unique index overai_usage_logs.idempotencyKey(≤255 chars; a NULL/omitted key is treated as distinct, so it does not dedup). A missing or random key means a retry or double-submit double-bills the merchant. Use a stable natural key for the turn — canonicallythreadId:lastUserMessageId, or a hash of the exact request inputs. NeverDate.now(),Math.random(), or a freshuuid().
(d) SDK-only data access
- All store data goes through
@tanqory/sdk. Nofetch/axiosto a Tanqory API from app code. - The only sanctioned raw
fetchis the central-auth code exchange / refresh the SDK cannot model (it rides an httpOnly cookie). Keep it isolated in your session module.
(e) Frontend standards
- Contracts: types come from
@tanqory/types(Zod is the source of truth). Do not hand-write DTOs. - Forms: react-hook-form + a Zod resolver from
@tanqory/types. - i18n:
next-intl+ the CDN loader. No hard-coded UI strings. - Env: the
@tanqory/envZod contract (fail-fast). No hand-parsedprocess.env. - UI: shadcn/Tailwind.
- Deploy: Next.js standalone, a
/api/healthprobe, and CSPframe-ancestors(never sendX-Frame-Options— it would block the frame).@tanqory/next-config'sbaseConfig()handles this.
Onboarding a new plugin
npx create-tanqory-app <handle>— gives you the manifest (tanqory.app.json), the embed route, a signed webhook handler, and the@tanqory/plugin-sdkwiring.- Declare scopes + URLs in the manifest; write a justification per scope (merchants see it at install).
- Register in the plugin registry (
plugins/plugin_versions) and publish a live version. The boot audience is thenapp:<handle>, resolved from the registry — no env edit, no redeploy to add a plugin. - Deploy your app to your origin. Tanqory frames it; the
the store router routes its
/api/*per request to the right cell. - AI? Add a
store-apiservice that calls the gateway with an existing feature and a deterministicidempotencyKey(checklist (c)).
The merchant-facing install / open / uninstall flow is the App Store — no bespoke host page, nav entry, or origin allow-list per plugin.
Registering a new AI feature
Reusing an existing feature under store-api needs no ai-api change. A
genuinely new caller or AiFeatureType is deliberately a five-place, reviewed
change — keep them in sync or the build/route fails:
AiFeatureTypeValuesZod enum in@tanqory/types(ai-usage.contract.ts).enum AiFeatureTypein admin-api centralschema.prisma(+ migration).- The restated union in
ai-apiai-usage.service.ts. AI_FEATURE_CATEGORY(QA vs metered) in the contract.CALLER_FEATURE_ALLOWLISTinai-apigateway.types.ts— grant the feature to the caller.
First-party & platform plugins (internal)
First-party plugins (apps Tanqory ships, marked isFirstParty in the registry)
follow the same standard as third-party apps. isFirstParty only removes the
Install/Uninstall buttons (the app is "included"); it does not authorize a
looser embedding.
Historically there was a second, bespoke first-party path — a static
AUTH_CODE_AUDIENCE_ORIGINS allow-list, a hardcoded origin RegExp, a bespoke
host page, and the full merchant session token over the bridge.
tanqory-platform-plugin-email still uses it and is the one exception, not
the template. It is a migration target, not a pattern to copy.
Do not build a new plugin on the bespoke path. Its migration:
- Adopt
@tanqory/plugin-sdk(drop the hand-rolled session/bridge/env/api-client). - Move to the generic embedded-app route + a registry install (audience
app:<handle>), so it receives a capped token instead of the session token, and honours uninstall. - Drop
isFirstParty's bespoke handling; the Install/Open buttons become real. - Bring it to checklist (e):
@tanqory/env, react-hook-form + Zod,next-intl+ CDN.
Until then, treat plugin-email's ✅ (bridge, SDK-only data, genuinely metered AI) as exemplary and its ❌ (session-token trust, hand-rolled foundation, env/forms/i18n) as the list of things a new plugin must not reproduce.
See also
- Quickstart (this section) — from nothing to an app in the dashboard.
- Authentication (this section) — boot code, token exchange, webhooks.
@tanqory/plugin-sdk,create-tanqory-app,@tanqory/app-bridge,@tanqory/next-config,@tanqory/env— the shared foundation.