Skip to main content

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_EXPIRED to the host — never navigate the iframe to a login page.
  • Use one long-lived iframe: a single catch-all route (/embed/[storeId]/[[...slug]]), storeId from the path, internal navigation reported with ROUTE_CHANGED.

(b) Auth & token — capped, never the full session​

  • Get tokens only via the bridge's requestToken(); make commerce calls only via apiRequest() (path-only — the host prefixes the store and signs with an app token capped to grantedScopes).
  • 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 ACTIVE installation → 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-api or a provider directly. Route every call through store-api, which forwards to the gateway (POST /api/gateway/completions).
  • The gateway bills by x-tanqory-caller against a feature type. The cheapest conformant path is to reuse an existing feature under the store-api caller (e.g. EMAIL, CONTENT, IMAGE) — zero ai-api edits. 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 over ai_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 — canonically threadId:lastUserMessageId, or a hash of the exact request inputs. Never Date.now(), Math.random(), or a fresh uuid().

(d) SDK-only data access​

  • All store data goes through @tanqory/sdk. No fetch/axios to a Tanqory API from app code.
  • The only sanctioned raw fetch is 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/env Zod contract (fail-fast). No hand-parsed process.env.
  • UI: shadcn/Tailwind.
  • Deploy: Next.js standalone, a /api/health probe, and CSP frame-ancestors (never send X-Frame-Options — it would block the frame). @tanqory/next-config's baseConfig() handles this.

Onboarding a new plugin​

  1. npx create-tanqory-app <handle> — gives you the manifest (tanqory.app.json), the embed route, a signed webhook handler, and the @tanqory/plugin-sdk wiring.
  2. Declare scopes + URLs in the manifest; write a justification per scope (merchants see it at install).
  3. Register in the plugin registry (plugins / plugin_versions) and publish a live version. The boot audience is then app:<handle>, resolved from the registry — no env edit, no redeploy to add a plugin.
  4. Deploy your app to your origin. Tanqory frames it; the the store router routes its /api/* per request to the right cell.
  5. AI? Add a store-api service that calls the gateway with an existing feature and a deterministic idempotencyKey (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:

  1. AiFeatureTypeValues Zod enum in @tanqory/types (ai-usage.contract.ts).
  2. enum AiFeatureType in admin-api central schema.prisma (+ migration).
  3. The restated union in ai-api ai-usage.service.ts.
  4. AI_FEATURE_CATEGORY (QA vs metered) in the contract.
  5. CALLER_FEATURE_ALLOWLIST in ai-api gateway.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:

  1. Adopt @tanqory/plugin-sdk (drop the hand-rolled session/bridge/env/api-client).
  2. 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.
  3. Drop isFirstParty's bespoke handling; the Install/Open buttons become real.
  4. 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.