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/:
| File | Role |
|---|---|
index.ts | The PluginManifest: id, navigation, routes and the page. The same contract as a @fayz-ai/plugin-*. |
data/types.ts | The data contract (LoyaltyDataProvider) that every backend implements. |
data/mock.ts | Mock provider — the plugin already runs without a database. |
data/supabase.ts | The real provider, going through the Fayz boundary (getSupabaseClientOptional), never importing the Supabase SDK directly. |
schema/index.ts | Where the plugin's migrations live. |
README.md | How 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.