06 · Your own plugin
Turning on catalog plugins is great, but at some point you want a capability that only exists in your product. The incubator is the way there: an app-local plugin that follows the same contract as the official plugins. 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 got created 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) 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 for going official. |
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',
navigation: [{ section: 'main', label: 'Loyalty', route: '/loyalty', icon: 'Puzzle' }],
routes: [{ path: '/loyalty', component: Page }],
}
}
To turn it on, add createLoyaltyPlugin() to the app config's plugins array (see the plugin's README.md). Then check the boundaries with doctor:
npx @fayz-ai/cli doctor
✓ You should see: the src/plugins/loyalty/ folder with its six files, and doctor reporting 0 error(s). data/supabase.ts already uses getSupabaseClientOptional — if you imported @supabase/supabase-js directly, doctor would flag the boundary violation.
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 ...>' }]
When you run fayz db apply, the incubator step (last in the order) applies those migrations along with everything else — which is exactly 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 a rewrite: the manifest doesn't change, you just move the folder. The generated README.md carries the full graduation checklist (validated contract, capability test, permissions, i18n, clean boundary). The same contract you followed here is the one the catalog plugins follow.
← Previous: 05 · Real data with Supabase · Next: 07 · Publish →