fayzfayz sdk
PTEN

Incubator

The incubator is where plugins are born: a folder inside your app that follows the same contract as the official plugins. It's the path to a capability that only exists in your product — and it's the step that turns you from an SDK user into an author.

Generate the plugin

npx @fayz-ai/cli create plugin loyalty
✓ Created app-local plugin "src/plugins/loyalty"

  Add createLoyaltyPlugin() to your app's plugins array (see src/plugins/loyalty/README.md).
  Run "fayz doctor" to check boundaries.

What appears in src/plugins/loyalty/:

FileRole
index.tsThe PluginManifest: id, navigation, routes and the page. The same contract as a @fayz-ai/plugin-*.
data/types.tsThe data contract (LoyaltyDataProvider) that every backend implements.
data/mock.tsMock provider — the plugin already runs without a database.
data/supabase.tsThe real provider, going through the Fayz boundary (getSupabaseClientOptional), never importing the Supabase SDK directly.
schema/index.tsWhere the plugin's migrations live.
README.mdHow to wire the plugin up, plus the graduation checklist.

The contract is the same as the official ones

index.ts exports a factory that returns a PluginManifest — id, navigation, routes and a page. It picks the provider automatically (Supabase when there's a backend, mock otherwise) via createSafeDataProvider:

export function createLoyaltyPlugin(options?: LoyaltyPluginOptions): PluginManifest {
  const provider =
    options?.dataProvider ??
    createSafeDataProvider(
      () => createSupabaseLoyaltyProvider(),
      () => createMockLoyaltyProvider(),
    )
  // ...
  return {
    id: 'loyalty',
    name: 'Loyalty',
    icon: 'Puzzle',
    version: '0.1.0',
    navigation: [{ section: 'main', position: 50, label: 'Loyalty', route: '/loyalty', icon: 'Puzzle' }],
    routes: [{ path: '/loyalty', component: Page }],
  }
}

To wire it up, add createLoyaltyPlugin() to your app config's plugins array (the generated README.md shows where). Then check the boundaries:

npx @fayz-ai/cli doctor

The boundary is mandatory

The generated data/supabase.ts never imports @supabase/supabase-js directly — it gets the client from getSupabaseClientOptional(), the Fayz boundary, and scopes the query by tenant_id:

import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'

// ...
const { data, error } = await sb()
  .from('loyalty')
  .select('*')
  .eq('tenant_id', getActiveTenantId())

If you swapped that for a direct import of the Supabase SDK, fayz doctor would flag the boundary break. That's the rule that keeps your plugin promotable — the details are in Patterns.

Where the migrations go

Your plugin's tables live in schema/index.ts and are referenced from the manifest via migrations: [...]:

migrations: [{ id: 'loyalty-0001', version: '0.1.0', sql: '<create table ...>' }]

During fayz db apply, the incubator step (the last in the order) applies these migrations along with the rest — which is why the spine → drizzle → seed → plugins → incubator order matters.

From incubator to official

Promoting an app-local plugin to an official @fayz-ai/plugin-* package is packaging, not rewriting: the manifest doesn't change, you just move the folder into its own package. The generated README.md carries the full graduation checklist (contract validated, capability test, migrations wired, permissions declared, complete i18n, clean boundary). The same contract you followed here is the one the catalog plugins follow — there's nothing an official plugin can do that yours can't.


Next: every field a plugin can declare, in Plugin manifest.