| title | Module API Documentation |
|---|---|
| description | This document defines the formal API contracts for all FlowWink modules. |
| category | reference |
Version: 1.1.0
Last Updated: 2025-01-20
This document defines the formal API contracts for all FlowWink modules. Each module exposes a well-defined interface that enables loose coupling, extensibility, and third-party module development.
┌─────────────────────────────────────────────────────────────────────────┐
│ MODULE REGISTRY │
│ (Central Coordinator) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Blog │ │Newsletter│ │ CRM │ │ Pages │ │ KB │ │
│ │ Module │ │ Module │ │ Module │ │ Module │ │ Module │ │
│ │ v1.0.0 │ │ v1.0.0 │ │ v1.0.0 │ │ v1.0.0 │ │ v1.0.0 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │ │
│ └─────────────┴─────────────┴─────────────┴─────────────┘ │
│ │ │
│ Supabase Database │
└─────────────────────────────────────────────────────────────────────────┘
- Documented Contracts - Every module has explicit input/output schemas
- Validation First - All data is validated at module boundaries using Zod
- Loose Coupling - Modules communicate through the Registry, not directly
- Traceability - All cross-module data includes source metadata
- MCP-Ready by Default - Modules with
db:skills are automatically operable via MCP, FlowPilot chat, and automations through the generic CRUD engine
Any module that registers skills with handler: "db:tablename" and a standard action enum (list, get, create, update, delete) is automatically accessible through:
- MCP — External AI clients (Cursor, Claude Desktop, OpenClaw) via
mcp-server - FlowPilot Chat — Admin and visitor conversations via
chat-completion - Automations — Cron and event-triggered via
agent-execute
To expose a module's skills via MCP, set mcp_exposed = true on the skill in agent_skills. The generic CRUD engine in agent-execute handles execution — no per-module edge function required. The table must be added to the GENERIC_CRUD_TABLES whitelist in agent-execute/index.ts.
Each module declares its capabilities:
| Capability | Description |
|---|---|
content:receive |
Can receive content from other modules |
content:produce |
Produces content that can be consumed by others |
webhook:trigger |
Triggers outbound webhooks on events |
webhook:receive |
Can receive inbound webhooks |
data:read |
Reads data from the database |
data:write |
Writes data to the database |
| Module | ID | Version | Capabilities |
|---|---|---|---|
| Blog | blog |
1.0.0 | content:receive, data:write, webhook:trigger |
| Newsletter | newsletter |
1.0.0 | content:receive, data:write |
| CRM | crm |
1.0.0 | content:receive, data:write, webhook:trigger |
| Pages | pages |
1.0.0 | content:receive, data:write, webhook:trigger |
| Knowledge Base | kb |
1.0.0 | content:receive, data:write |
| Products | products |
1.0.0 | content:receive, data:write, webhook:trigger |
| Booking | booking |
1.0.0 | content:receive, data:write, webhook:trigger |
| Global Blocks | global-blocks |
1.0.0 | content:receive, data:write |
| Media Library | media |
1.0.0 | data:read, data:write |
| Deals | deals |
1.0.0 | content:receive, data:write, webhook:trigger |
| Companies | companies |
1.0.0 | content:receive, data:write |
| Forms | forms |
1.0.0 | content:receive, data:write, webhook:trigger |
| Orders | orders |
1.0.0 | content:receive, data:write, webhook:trigger |
| Webinars | webinars |
1.0.0 | content:receive, data:write |
Some modules have dependencies on other modules. When a parent module is disabled, dependent modules are automatically disabled as well. When enabling a dependent module, its parent is automatically enabled.
| Dependent Module | Requires |
|---|---|
| Orders | Products |
| Deals | Leads |
| Live Support | Chat |
Certain content blocks require their associated module to be enabled. When the required module is disabled, the block remains visible in the Block Selector but shows a warning indicator. Users can still add the block, but it may not function correctly.
| Block Type | Required Module |
|---|---|
article-grid |
Blog |
chat |
AI Chat |
newsletter |
Newsletter |
booking |
Bookings |
products, cart |
Products |
kb-featured, kb-hub, kb-search, kb-accordion |
Knowledge Base |
webinar |
Webinars |
This "hybrid guard" approach provides visual feedback without blocking the user, keeping the system flexible while preventing silent errors.
ID: blog
Capabilities: content:receive, data:write, webhook:trigger
interface BlogModuleInput {
// Required
title: string; // Post title (max 200 chars)
content: TiptapDocument | string; // Rich text content
// Optional
excerpt?: string; // Summary (max 500 chars)
featured_image?: string; // Image URL
featured_image_alt?: string; // Image alt text
// Metadata
meta?: {
keywords?: string[]; // SEO keywords
description?: string; // Meta description
source_module?: string; // Originating module ID
source_id?: string; // Original content ID
};
// Publishing options
options?: {
status: 'draft' | 'published'; // Default: 'draft'
schedule_at?: string; // ISO 8601 datetime
author_id?: string; // Author profile ID
category_ids?: string[]; // Category UUIDs
tag_ids?: string[]; // Tag UUIDs
};
}interface BlogModuleOutput {
success: boolean;
id: string; // Created post UUID
slug: string; // URL slug
url: string; // Full URL path
status: string; // Final status
published_at?: string; // If published
error?: string; // If success is false
}import { moduleRegistry } from '@/lib/module-registry';
const result = await moduleRegistry.publish('blog', {
title: 'How to Build Modular Systems',
content: tiptapDocument,
excerpt: 'A guide to building maintainable software...',
meta: {
source_module: 'content-campaign',
source_id: 'proposal-123'
},
options: {
status: 'published'
}
});
console.log(result);
// { success: true, id: 'uuid', slug: 'how-to-build-modular-systems', url: '/blog/how-to-build-modular-systems' }ID: newsletter
Capabilities: content:receive, data:write
interface NewsletterModuleInput {
// Required
subject: string; // Email subject (max 150 chars)
// Content (one of these)
content_html?: string; // Pre-rendered HTML
content_json?: NewsletterBlock[]; // Structured blocks
content_tiptap?: TiptapDocument; // Rich text document
// Optional
preview_text?: string; // Email preview text
// Metadata
meta?: {
source_module?: string;
source_id?: string;
};
// Options
options?: {
status: 'draft' | 'scheduled'; // Default: 'draft'
send_at?: string; // ISO 8601 datetime (for scheduled)
};
}interface NewsletterModuleOutput {
success: boolean;
id: string; // Newsletter UUID
status: string; // Final status
subscriber_count?: number; // If sending
error?: string;
}ID: webhook
Capabilities: webhook:trigger, webhook:receive
interface WebhookModuleInput {
// Required
event: WebhookEventType; // Event type to trigger
payload: Record<string, unknown>; // Event-specific data
// Optional
channel?: string; // Specific webhook name filter
// Metadata
meta?: {
source_module: string;
trace_id?: string; // For request tracing
};
}
type WebhookEventType =
| 'page.published'
| 'page.deleted'
| 'blog_post.published'
| 'blog_post.updated'
| 'blog_post.deleted'
| 'form.submitted'
| 'newsletter.subscribed'
| 'newsletter.unsubscribed'
| 'order.created'
| 'order.paid'
| 'order.shipped'
| 'order.cancelled'
| 'booking.created'
| 'booking.confirmed'
| 'booking.cancelled'
| 'lead.created'
| 'lead.qualified'
| 'content.published'; // Generic content eventinterface WebhookModuleOutput {
success: boolean;
triggered_count: number; // Number of webhooks triggered
results: Array<{
webhook_id: string;
webhook_name: string;
success: boolean;
status_code?: number;
error?: string;
}>;
}ID: crm
Capabilities: content:receive, data:write, webhook:trigger
interface CRMLeadInput {
// Required
email: string;
// Optional
name?: string;
phone?: string;
source: string; // Where lead came from
source_id?: string; // Reference to source record
// Scoring
initial_score?: number;
// Metadata
meta?: {
source_module?: string;
form_data?: Record<string, unknown>;
};
}interface CRMLeadOutput {
success: boolean;
lead_id: string;
is_new: boolean; // True if created, false if existing
score: number;
status: LeadStatus;
error?: string;
}ID: pages
Capabilities: content:receive, data:write, webhook:trigger
interface PageModuleInput {
// Required
title: string; // Page title (max 200 chars)
content: ContentBlock[] | TiptapDocument | string; // Page content
// Optional
slug?: string; // Auto-generated if not provided
// Metadata
meta?: {
source_module?: string;
source_id?: string;
seo_title?: string; // SEO title (max 60 chars)
seo_description?: string; // Meta description (max 160 chars)
};
// Options
options?: {
status: 'draft' | 'published'; // Default: 'draft'
show_in_menu: boolean; // Default: false
menu_order?: number;
schedule_at?: string; // ISO 8601 datetime
};
}interface PageModuleOutput {
success: boolean;
id: string; // Created page UUID
slug: string; // URL slug
url: string; // Full URL path (e.g., "/about-us")
status: string; // Final status
error?: string;
}const result = await moduleRegistry.publish('pages', {
title: 'About Our Company',
content: [
{ id: 'uuid', type: 'hero', data: { title: 'About Us', subtitle: 'Our story' } },
{ id: 'uuid', type: 'text', data: { content: tiptapDoc } }
],
meta: {
seo_title: 'About Us - Company Name',
seo_description: 'Learn about our company history and values.'
},
options: {
status: 'published',
show_in_menu: true,
menu_order: 2
}
});ID: kb
Capabilities: content:receive, data:write
interface KBArticleModuleInput {
// Required
title: string; // Article title (max 200 chars)
question: string; // FAQ question (max 500 chars)
category_id: string; // KB category UUID
answer: TiptapDocument | string; // Answer content
// Optional
slug?: string; // Auto-generated if not provided
// Metadata
meta?: {
source_module?: string;
source_id?: string;
seo_title?: string;
seo_description?: string;
};
// Options
options?: {
is_published: boolean; // Default: true
is_featured: boolean; // Default: false
};
}interface KBArticleModuleOutput {
success: boolean;
id: string; // Created article UUID
slug: string; // URL slug
url: string; // Full URL path (e.g., "/kb/how-to-reset-password")
error?: string;
}const result = await moduleRegistry.publish('kb', {
title: 'Password Reset Guide',
question: 'How do I reset my password?',
category_id: 'category-uuid',
answer: {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Follow these steps...' }] }
]
},
options: {
is_published: true,
}
});The Content Campaign module orchestrates publishing to multiple channels:
┌────────────────────┐
│ Content Campaign │
│ │
│ Proposal with: │
│ - Pillar content │
│ - Channel variants│
└─────────┬──────────┘
│
│ User clicks "Publish to Blog"
▼
┌────────────────────┐
│ Module Registry │
│ │
│ 1. Get 'blog' │
│ 2. Validate input │
│ 3. Execute │
└─────────┬──────────┘
│
│ BlogModuleInput {
│ title: variant.title,
│ content: variant.content,
│ meta: { source_module: 'content-campaign', source_id: proposal.id }
│ }
▼
┌────────────────────┐
│ Blog Module │
│ │
│ 1. Transform │
│ 2. Insert to DB │
│ 3. Trigger hooks │
└─────────┬──────────┘
│
│ BlogModuleOutput {
│ success: true,
│ id: 'new-post-id',
│ slug: 'generated-slug',
│ url: '/blog/generated-slug'
│ }
▼
┌────────────────────┐
│ Content Campaign │
│ │
│ Update proposal: │
│ - published_channels │
│ - status │
└────────────────────┘
Beyond the Module Registry, FlowWink enforces data ownership boundaries at the database level. Shared tables (like leads and lead_activities) have a single owner module that exposes utility functions. Other modules must use these functions — never write directly.
| Table | Owner | Contract File |
|---|---|---|
leads |
CRM | src/lib/lead-utils.ts |
lead_activities |
CRM | src/lib/lead-utils.ts |
companies |
CRM | src/lib/lead-utils.ts |
blog_posts |
Blog | src/lib/module-registry.ts (blog module) |
pages |
Pages | src/lib/module-registry.ts (pages module) |
All modules that interact with leads must use these functions.
Public surfaces write through SECURITY DEFINER RPCs, never the table.
leads carries no WITH CHECK (true) policy since 20260821070000 — anonymous
visitors reach the CRM only through ingest_form_lead / ingest_webinar_lead,
and signed-in writes follow the role/module matrix (can_access_module('leads')).
A new public lead source gets its own sister RPC with fail-closed validation;
it does not get a table policy.
| Function | Purpose | Used By |
|---|---|---|
createLeadFromForm() |
Create/update lead from form submission | FormBlock (via ingest_form_lead RPC) |
createLeadFromBooking() |
Create/update lead from booking | Staff only — no caller; booking leads are created server-side in comms-send/booking_confirmation.ts |
createLeadFromWebinar() |
Create/update lead from webinar registration | WebinarBlock (via ingest_webinar_lead RPC) |
addLeadActivity() |
Log activity on a lead | All modules via createLeadFrom*, Deals |
updateLeadStatus() |
Change lead status | Deals module |
trackNewsletterActivity() |
Track email opens/clicks | Newsletter |
qualifyLead() |
Trigger AI qualification | Called internally by createLeadFrom* |
Each createLeadFrom* function handles:
- Find or create lead by email
- Auto-match company by email domain
- Trigger company enrichment for new companies
- Log activity with correct points
- Trigger AI qualification
Defined in ACTIVITY_POINTS within lead-utils.ts:
| Activity | Points |
|---|---|
webinar_register |
15 |
form_submit |
10 |
booking |
10 |
newsletter_subscribe |
8 |
link_click |
5 |
call |
5 |
email_open |
3 |
page_visit |
2 |
- Never write directly to
leadsorlead_activitiesfrom hooks or components outside CRM - Never import
supabaseand queryleadstable from a non-CRM module — use the contract functions - Reading shared tables for analytics/display is OK (e.g.,
useAnalytics.ts) - When adding a new lead source, create a
createLeadFrom*()function inlead-utils.ts - When a module needs to change lead status, use
updateLeadStatus()— not a direct update
// ❌ BAD — Direct write from webinar module
await supabase.from('leads').insert({ email, source: 'webinar' });
await supabase.from('lead_activities').insert({ lead_id, type: 'webinar_register' });
// ✅ GOOD — Via contract
import { createLeadFromWebinar } from '@/lib/lead-utils';
const { leadId } = await createLeadFromWebinar({ email, name, webinarId, webinarTitle });
// ❌ BAD — Direct status update from deals module
await supabase.from('leads').update({ status: 'customer' }).eq('id', leadId);
// ✅ GOOD — Via contract
import { updateLeadStatus } from '@/lib/lead-utils';
await updateLeadStatus(leadId, 'customer', { convertedAt: true });These files may access leads/lead_activities directly because they are CRM-owned:
src/hooks/useLeads.ts— CRM CRUD hookssrc/hooks/useActivities.ts— CRM activity hookssrc/components/admin/CreateLeadDialog.tsx— Admin manual creation (usesaddLeadActivity)src/hooks/useCsvImportExport.ts— Bulk importsrc/components/admin/ResetSiteDialog.tsx— System reset (delete all)src/lib/module-registry.ts(CRM module only) — CRM module's publish()
Add your module's input/output types to src/types/module-contracts.ts:
// 1. Define input schema
export const myModuleInputSchema = z.object({
required_field: z.string(),
optional_field: z.string().optional(),
meta: moduleMetaSchema.optional(),
});
export type MyModuleInput = z.infer<typeof myModuleInputSchema>;
// 2. Define output schema
export const myModuleOutputSchema = z.object({
success: z.boolean(),
id: z.string(),
error: z.string().optional(),
});
export type MyModuleOutput = z.infer<typeof myModuleOutputSchema>;Create your module implementation:
import { ModuleDefinition } from '@/lib/module-registry';
import { MyModuleInput, MyModuleOutput, myModuleInputSchema, myModuleOutputSchema } from '@/types/module-contracts';
export const myModule: ModuleDefinition<MyModuleInput, MyModuleOutput> = {
id: 'my-module',
name: 'My Module',
version: '1.0.0',
capabilities: ['content:receive', 'data:write'],
inputSchema: myModuleInputSchema,
outputSchema: myModuleOutputSchema,
async publish(input: MyModuleInput): Promise<MyModuleOutput> {
// Your implementation here
return {
success: true,
id: 'created-id',
};
}
};Add to the registry in src/lib/module-registry.ts:
import { myModule } from './modules/my-module';
moduleRegistry.register(myModule);External systems can act as modules via webhooks:
The inbound endpoint is signal-ingest (there is no dedicated
module-webhook function). It accepts signals from any external operator
(webhooks, browser extension, integrations), stores them in
agent_memory/agent_activity, and fires automation signals:
POST /functions/v1/signal-ingest
Content-Type: application/json
Authorization: Bearer <site_settings.signal_ingest_token>
{
"note": "signal: lead.created from external-crm",
"payload": {
"email": "user@example.com",
"name": "John Doe"
}
}
Configure a webhook with event content.published:
{
"event": "content.published",
"timestamp": "2025-01-19T12:00:00Z",
"data": {
"module": "blog",
"id": "post-uuid",
"title": "New Post Title",
"url": "/blog/new-post"
},
"meta": {
"source_module": "content-campaign",
"source_id": "proposal-uuid"
}
}All modules return consistent error structures:
interface ModuleError {
success: false;
error: string; // Human-readable message
error_code?: string; // Machine-readable code
validation_errors?: Array<{
field: string;
message: string;
}>;
}| Code | Description |
|---|---|
VALIDATION_ERROR |
Input failed schema validation |
NOT_FOUND |
Referenced resource doesn't exist |
PERMISSION_DENIED |
User lacks required permissions |
DUPLICATE |
Resource already exists |
EXTERNAL_ERROR |
Third-party service failed |
Before:
const { data } = await supabase
.from('blog_posts')
.insert({ title, content: contentJson });After:
const result = await moduleRegistry.publish('blog', {
title,
content: contentJson,
meta: { source_module: 'my-feature' }
});- Validation - Input is validated before database write
- Consistency - All blog posts created same way
- Hooks - Webhooks triggered automatically
- Traceability - Origin tracked in metadata