Writing Plugins
Build powerful server-side extensions with custom objects, routes, scheduled jobs, and event listeners.
Writing Plugins
Plugins are the primary extension mechanism in ObjectStack. They allow you to add custom business logic, integrate external services, define new Objects, register API routes, schedule background jobs, and respond to system events.
Think of plugins as microservices within your ObjectStack runtime - isolated, versioned, and composable.
🎯 What Can Plugins Do?
Define Objects
Create custom business entities with fields, relationships, and validations
API Routes
Register custom HTTP endpoints for webhooks or integrations
Scheduled Jobs
Run background tasks on cron schedules
Event Listeners
React to data changes (onCreate, onUpdate, onDelete)
Custom Drivers
Connect to external databases or data sources
Permission Logic
Implement custom authorization rules
🏗️ Where Plugins Live
In the ObjectStack ecosystem, plugins are organized into two main categories:
| Location | Description | Examples |
|---|---|---|
packages/plugins/* | Core Plugins. Maintained by the ObjectStack team, these provide essential integrations like HTTP servers, drivers, and testing tools. | plugin-hono-server, plugin-msw, driver-memory |
examples/* | Example Plugins. Reference implementations and educational templates to help you build your own. | plugin-bi |
When developing your own plugins, you can create them in your own repository or add them to your project's plugins/ directory.
📋 Prerequisites
Before building a plugin, ensure you have:
- Node.js 18+ and npm 9+
- TypeScript 5+ knowledge
- Zod familiarity (schema validation library)
- Understanding of ObjectQL and ObjectOS
🚀 Quick Start
Step 1: Create a Plugin Project
# Using the official generator
npm create @objectstack/plugin my-crm-plugin
cd my-crm-plugin
npm installThis generates a project structure:
my-crm-plugin/
├── src/
│ ├── index.ts # Plugin entry point
│ ├── objects/ # Object definitions
│ │ └── account.object.ts
│ ├── routes/ # API route handlers
│ │ └── webhooks.ts
│ ├── jobs/ # Scheduled tasks
│ │ └── sync.ts
│ └── events/ # Event listeners
│ └── account-created.ts
├── package.json
├── tsconfig.json
└── objectstack.config.ts # Plugin manifestStep 2: Define Your Plugin Manifest
The manifest (objectstack.config.ts) declares your plugin's metadata:
// objectstack.config.ts
import { defineManifest } from '@objectstack/spec';
export default defineManifest({
name: 'my_crm_plugin',
version: '1.0.0',
label: 'My CRM Plugin',
description: 'Customer relationship management for ObjectStack',
author: 'Your Name',
license: 'MIT',
// Dependencies
dependencies: {
'@objectstack/core': '^1.0.0',
},
// Permissions required
permissions: [
'object.create',
'object.read',
'api.register',
'schedule.create',
],
// Configuration schema
settings: {
apiKey: {
type: 'text',
label: 'API Key',
required: true,
sensitive: true,
},
syncInterval: {
type: 'number',
label: 'Sync Interval (minutes)',
default: 60,
},
},
});Step 3: Implement the Plugin Lifecycle
// src/index.ts
import { Plugin } from '@objectstack/core';
import { Account } from './objects/account.object';
import { registerWebhooks } from './routes/webhooks';
import { startSyncJob } from './jobs/sync';
const myPlugin: Plugin = {
name: 'my-crm-plugin',
version: '1.0.0',
/**
* Called when the plugin is enabled.
* Register objects, routes, jobs, and event listeners here.
*/
init: async (context) => {
// context comes from @objectstack/core
const { logger } = context;
logger.info('My CRM Plugin enabled');
/*
Note: Accessing services like 'ql' or 'router' depends on
what plugins are installed in the kernel.
const ql = context.getService('objectql');
*/
},
// Register custom objects
await ql.registerObject(Account);
// Register API routes
registerWebhooks(router);
// Schedule background jobs
const interval = await os.getConfig('my_crm_plugin.syncInterval');
scheduler.schedule('sync-accounts', `*/${interval} * * * *`, () => {
return startSyncJob(context);
});
// Listen to events
ql.on('account.created', async (event) => {
logger.info('New account created', { id: event.record.id });
await storage.set(`last_account_id`, event.record.id);
});
},
/**
* Called when the plugin is disabled.
* Clean up resources here.
*/
onDisable: async (context: PluginContext) => {
context.logger.info('My CRM Plugin disabled');
// Unregister listeners, close connections, etc.
},
/**
* Called when plugin settings are updated.
*/
onSettingsChange: async (context: PluginContext, oldSettings, newSettings) => {
context.logger.info('Settings updated', { oldSettings, newSettings });
},
};🗂️ Defining Custom Objects
Objects are the core data model of ObjectStack. Define them using ObjectSchema:
// src/objects/account.object.ts
import { ObjectSchema, Field } from '@objectstack/spec';
export const Account = ObjectSchema.create({
name: 'account',
label: 'Account',
pluralLabel: 'Accounts',
icon: 'building',
description: 'Customer organizations',
// Primary display field
nameField: 'company_name',
fields: {
// ============================================================================
// Basic Information
// ============================================================================
company_name: Field.text({
label: 'Company Name',
required: true,
maxLength: 200,
unique: true,
}),
website: Field.url({
label: 'Website',
}),
industry: Field.select({
label: 'Industry',
options: [
{ label: 'Technology', value: 'tech' },
{ label: 'Healthcare', value: 'healthcare' },
{ label: 'Finance', value: 'finance' },
{ label: 'Retail', value: 'retail' },
],
}),
// ============================================================================
// Contact Information
// ============================================================================
phone: Field.phone({
label: 'Phone',
}),
email: Field.email({
label: 'Email',
}),
billing_address: Field.address({
label: 'Billing Address',
}),
// ============================================================================
// Business Metrics
// ============================================================================
annual_revenue: Field.currency({
label: 'Annual Revenue',
currency: 'USD',
}),
employee_count: Field.number({
label: 'Employee Count',
min: 1,
}),
// ============================================================================
// Status & Lifecycle
// ============================================================================
status: Field.select({
label: 'Status',
options: [
{ label: 'Prospect', value: 'prospect', default: true },
{ label: 'Customer', value: 'customer' },
{ label: 'Churned', value: 'churned' },
],
}),
active: Field.checkbox({
label: 'Active',
default: true,
}),
// ============================================================================
// Relationships
// ============================================================================
parent_account: Field.lookup('account', {
label: 'Parent Account',
description: 'Parent organization (for subsidiaries)',
}),
owner: Field.lookup('user', {
label: 'Account Owner',
required: true,
defaultValue: '$currentUser',
}),
// ============================================================================
// Calculated Fields
// ============================================================================
lifetime_value: Field.formula({
label: 'Lifetime Value',
returnType: 'number',
expression: 'SUM(opportunities.amount WHERE stage = "closed_won")',
}),
days_since_created: Field.formula({
label: 'Days Since Created',
returnType: 'number',
expression: 'DATEDIFF(NOW(), created_at)',
}),
},
// ============================================================================
// Object Capabilities
// ============================================================================
enable: {
apiEnabled: true,
trackHistory: true,
allowComments: true,
allowAttachments: true,
searchEnabled: true,
},
// ============================================================================
// Indexes for Performance
// ============================================================================
indexes: [
{ fields: ['company_name'], unique: true },
{ fields: ['status', 'active'] },
{ fields: ['owner', 'status'] },
],
});🌐 Registering API Routes
Add custom HTTP endpoints for webhooks, integrations, or custom APIs:
// src/routes/webhooks.ts
import { IHttpServer } from '@objectstack/core';
import { z } from 'zod';
const WebhookPayloadSchema = z.object({
event: z.enum(['account.created', 'account.updated']),
data: z.record(z.any()),
timestamp: z.string(),
});
export function registerWebhooks(server: IHttpServer) {
// ============================================================================
// POST /api/webhooks/account
// ============================================================================
server.post('/api/webhooks/account', async (req, res) => {
try {
// Validate webhook payload
const payload = WebhookPayloadSchema.parse(req.body);
// Verify webhook signature (example)
const signature = req.headers['x-webhook-signature'];
if (!verifySignature(signature, req.body)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process the webhook
if (payload.event === 'account.created') {
await handleAccountCreated(payload.data);
}
res.status(200).json({ success: true });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// ============================================================================
// GET /api/integrations/sync
// ============================================================================
server.get('/api/integrations/sync', async (req, res) => {
const { ql, logger } = req.context;
try {
// Fetch accounts from external API
const externalAccounts = await fetchFromExternalAPI();
// Sync to ObjectStack
for (const account of externalAccounts) {
await ql.object('account').upsert({
external_id: account.id,
company_name: account.name,
website: account.website,
});
}
logger.info('Sync completed', { count: externalAccounts.length });
res.json({
success: true,
synced: externalAccounts.length,
});
} catch (error) {
logger.error('Sync failed', { error });
res.status(500).json({ error: 'Sync failed' });
}
});
}
// Helper functions
function verifySignature(signature: string, body: any): boolean {
// Implement HMAC verification
return true;
}
async function handleAccountCreated(data: any) {
// Send notification, trigger workflow, etc.
}
async function fetchFromExternalAPI() {
// Call external API
return [];
}⏰ Scheduling Background Jobs
Run tasks periodically using cron expressions:
// src/jobs/sync.ts
import { PluginContext } from '@objectstack/core';
export async function startSyncJob(context: PluginContext) {
const { ql, os, logger, storage } = context;
logger.info('Starting account sync job');
try {
// Get last sync timestamp
const lastSync = await storage.get('last_sync_time');
// Fetch updated accounts from external source
const apiKey = await os.getConfig('my_crm_plugin.apiKey');
const accounts = await fetchAccountsSince(apiKey, lastSync);
// Upsert to ObjectStack
let syncedCount = 0;
for (const account of accounts) {
await ql.object('account').upsert({
external_id: account.id,
company_name: account.name,
website: account.website,
annual_revenue: account.revenue,
});
syncedCount++;
}
// Update last sync time
await storage.set('last_sync_time', new Date().toISOString());
logger.info('Sync job completed', { synced: syncedCount });
} catch (error) {
logger.error('Sync job failed', { error });
throw error;
}
}
async function fetchAccountsSince(apiKey: string, since: string) {
// Implement external API call
return [];
}Register the job in onEnable:
scheduler.schedule('sync-accounts', '*/30 * * * *', () => {
return startSyncJob(context);
});Cron Expression Examples:
*/15 * * * *- Every 15 minutes0 */2 * * *- Every 2 hours0 9 * * 1-5- 9 AM Monday-Friday0 0 * * 0- Every Sunday at midnight
🎧 Event Listeners
React to data changes in real-time:
// src/events/account-created.ts
import { PluginContext } from '@objectstack/core';
export function registerEventListeners(context: PluginContext) {
const { ql, logger } = context;
// ============================================================================
// Listen to account creation
// ============================================================================
ql.on('account.created', async (event) => {
logger.info('Account created', { id: event.record.id });
// Send welcome email
await sendWelcomeEmail(event.record);
// Create default opportunities
await ql.object('opportunity').create({
name: `${event.record.company_name} - Discovery`,
account: event.record.id,
stage: 'discovery',
});
});
// ============================================================================
// Listen to status changes
// ============================================================================
ql.on('account.updated', async (event) => {
const { oldValues, newValues } = event;
// Check if status changed to "customer"
if (oldValues.status !== 'customer' && newValues.status === 'customer') {
logger.info('Account converted to customer', { id: event.record.id });
// Trigger onboarding workflow
await triggerOnboarding(event.record);
}
});
// ============================================================================
// Prevent deletion of active accounts
// ============================================================================
ql.on('account.beforeDelete', async (event) => {
if (event.record.active) {
throw new Error('Cannot delete active accounts');
}
});
}
async function sendWelcomeEmail(account: any) {
// Send email
}
async function triggerOnboarding(account: any) {
// Start onboarding flow
}Available Events:
{object}.beforeCreate- Before record creation{object}.created- After record created{object}.beforeUpdate- Before update{object}.updated- After update{object}.beforeDelete- Before deletion{object}.deleted- After deletion
💾 Using Scoped Storage
Each plugin has isolated key-value storage:
// Save data
await context.storage.set('api_token', 'abc123');
await context.storage.set('sync_count', 42);
// Retrieve data
const token = await context.storage.get('api_token');
const count = await context.storage.get('sync_count');
// Delete data
await context.storage.delete('api_token');
// Store complex objects (automatically JSON-serialized)
await context.storage.set('config', {
enabled: true,
lastRun: new Date(),
settings: { foo: 'bar' },
});🧪 Testing Your Plugin
Unit Tests
// tests/account.test.ts
import { describe, it, expect } from 'vitest';
import { Account } from '../src/objects/account.object';
describe('Account Object', () => {
it('should have required fields', () => {
expect(Account.fields.company_name.required).toBe(true);
});
it('should validate industry options', () => {
const validIndustries = Account.fields.industry.options.map(o => o.value);
expect(validIndustries).toContain('tech');
});
});Integration Tests
// tests/integration.test.ts
import { createTestContext } from '@objectstack/testing';
import MyPlugin from '../src/index';
describe('Plugin Integration', () => {
it('should register account object', async () => {
const context = await createTestContext({
plugins: [MyPlugin],
});
const account = await context.ql.object('account').create({
company_name: 'Acme Corp',
status: 'prospect',
});
expect(account.id).toBeDefined();
expect(account.company_name).toBe('Acme Corp');
});
it('should trigger event on creation', async () => {
const context = await createTestContext({
plugins: [MyPlugin],
});
let eventFired = false;
context.ql.on('account.created', () => {
eventFired = true;
});
await context.ql.object('account').create({
company_name: 'Test Inc',
});
expect(eventFired).toBe(true);
});
});📦 Building & Packaging
Build for Production
# Compile TypeScript
npm run build
# Output: dist/index.jsPackage Structure
{
"name": "@mycompany/objectstack-plugin-crm",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"keywords": ["objectstack", "plugin", "crm"],
"files": ["dist", "README.md", "LICENSE"],
"objectstack": {
"type": "plugin",
"entry": "./dist/index.js",
"manifest": "./dist/objectstack.config.js"
},
"dependencies": {
"@objectstack/spec": "^1.0.0"
},
"peerDependencies": {
"@objectstack/kernel": "^1.0.0"
}
}Package Type Options:
The type field in the objectstack configuration can be one of:
plugin- General-purpose functionality extension (default for most plugins)app- Business application packagedriver- Database/external service adapter (Postgres, MongoDB, S3)module- Reusable code library/shared moduleobjectql- Core data engine implementationgateway- API protocol entry point (GraphQL, REST, RPC)adapter- Runtime container (Express, Hono, Fastify, Serverless)
For most use cases, use plugin for business logic extensions and driver for data source integrations.
🚀 Publishing
To NPM
npm login
npm publish --access publicTo ObjectStack Hub
objectstack login
objectstack publish --registry hub.objectstack.dev✅ Best Practices
1. Use Zod for Validation
import { z } from 'zod';
const SettingsSchema = z.object({
apiUrl: z.string().url(),
timeout: z.number().min(1000).max(30000),
});
const settings = SettingsSchema.parse(userInput);2. Handle Errors Gracefully
try {
await externalAPI.call();
} catch (error) {
context.logger.error('External API failed', { error });
// Fallback or retry logic
}3. Use Transactions
const tx = await context.ql.beginTransaction();
try {
await tx.object('account').create({ ... });
await tx.object('contact').create({ ... });
await tx.commit();
} catch (error) {
await tx.rollback();
throw error;
}4. Optimize Queries
// Bad: N+1 queries
for (const account of accounts) {
const contacts = await ql.object('contact').find({ account: account.id });
}
// Good: Single query with relationships
const accounts = await ql.object('account').find({
include: ['contacts'],
});5. Document Your Plugin
Include comprehensive README with:
- Installation instructions
- Configuration options
- API endpoints
- Event triggers
- Examples
🔗 Related Resources
🆘 Troubleshooting
Plugin Not Loading
Check the manifest:
objectstack validate ./objectstack.config.tsEvents Not Firing
Ensure you registered listeners in onEnable:
ql.on('account.created', handler); // ✅ CorrectPermission Denied
Add required permissions to manifest:
permissions: ['object.create', 'api.register']Ready to build your plugin? Start with the Quick Start guide! 🚀