Plugin Package Specification
Manifest structure, directory layout, dependency management, and distribution format
Plugin Package Specification
Protocol spec. This page describes the target plugin packaging
specification — plugin.manifest.ts, semantic-version dependency resolution,
provider/consumer contracts, lifecycle hooks. The current runtime implements
plugins as classes/objects matching the Plugin interface from
@objectstack/core, and manifests as plain objects matching
ManifestSchema from @objectstack/spec/kernel. There is no definePlugin()
helper today — that wrapper is the proposed ergonomic shape. Treat the
snippets below as design intent for the manifest spec, not paste-ready code.
A plugin is the unit of distribution in ObjectStack. It packages ObjectQL schemas, ObjectUI layouts, business logic, and configuration into a self-contained module that can be installed, upgraded, and removed independently.
Plugin Manifest
Every plugin must have a manifest file that declares its identity, dependencies, and capabilities.
Manifest Location
my-plugin/
plugin.manifest.ts ← TypeScript (recommended)
plugin.manifest.yml ← YAML (alternative)
plugin.manifest.json ← JSON (alternative)Recommendation: Use TypeScript manifest for type safety and validation. ObjectStack auto-generates JSON schema from TypeScript.
Manifest Schema
// plugin.manifest.ts
import { definePlugin } from '@objectstack/core';
export default definePlugin({
// IDENTITY
name: '@mycompany/crm',
version: '1.5.0',
displayName: 'Customer Relationship Management',
description: 'Complete CRM with accounts, contacts, and opportunities',
author: 'MyCompany <dev@mycompany.com>',
license: 'MIT',
homepage: 'https://github.com/mycompany/crm',
// DEPENDENCIES
dependencies: {
'@objectstack/core': '^2.0.0',
'@mycompany/base': '>=1.0.0 <2.0.0',
},
// OPTIONAL DEPENDENCIES (plugin works without these)
optionalDependencies: {
'@vendor/email': '^3.0.0',
},
// PEER DEPENDENCIES (consumer must install)
peerDependencies: {
'@objectstack/ui': '^2.0.0',
},
// METADATA REGISTRATION
metadata: {
// ObjectQL objects
objects: [
'src/objects/**/*.object.ts',
],
// ObjectUI views
views: [
'src/views/**/*.view.ts',
],
// Business logic
triggers: [
'src/triggers/**/*.trigger.ts',
],
workflows: [
'src/workflows/**/*.workflow.ts',
],
// Internationalization
translations: [
'i18n/**/*.json',
],
// Configuration schema
configSchema: 'src/config.schema.ts',
},
// LIFECYCLE HOOKS
lifecycle: {
onInstall: 'src/lifecycle/install.ts',
onUninstall: 'src/lifecycle/uninstall.ts',
onEnable: 'src/lifecycle/enable.ts',
onDisable: 'src/lifecycle/disable.ts',
onUpgrade: 'src/lifecycle/upgrade.ts',
onBoot: 'src/lifecycle/boot.ts',
},
// PERMISSIONS
permissions: {
// System capabilities plugin requires
system: [
'network.http', // Make HTTP requests
'storage.database', // Database access
'storage.cache', // Redis/cache access
],
// Objects plugin creates/manages
objects: [
'account',
'contact',
'opportunity',
],
},
// CONFIGURATION
config: {
// Default values
defaults: {
maxAccountsPerUser: 1000,
enableOpportunityScoring: true,
},
// Secrets (encrypted at rest)
secrets: [
'apiKey',
'webhookSecret',
],
},
// MARKETPLACE METADATA
marketplace: {
// Category for marketplace listing
category: 'crm',
// Screenshots
screenshots: [
'assets/screenshot-1.png',
'assets/screenshot-2.png',
],
// Pricing
pricing: {
model: 'subscription',
price: 29.99,
currency: 'USD',
interval: 'month',
},
// Compatibility
compatibility: {
objectstack: '>=2.0.0 <3.0.0',
node: '>=18.0.0',
},
},
});Directory Structure
A well-organized plugin follows this standard structure:
@mycompany/crm/
├── plugin.manifest.ts # Plugin manifest (required)
├── package.json # NPM package metadata
├── README.md # Documentation
├── CHANGELOG.md # Version history
├── LICENSE # License text
│
├── src/
│ ├── objects/ # ObjectQL schemas
│ │ ├── account.object.ts
│ │ ├── contact.object.ts
│ │ └── opportunity.object.ts
│ │
│ ├── views/ # ObjectUI layouts
│ │ ├── account_list.view.ts
│ │ ├── account_detail.view.ts
│ │ └── opportunity_kanban.view.ts
│ │
│ ├── triggers/ # Business logic triggers
│ │ ├── account_validation.trigger.ts
│ │ └── opportunity_scoring.trigger.ts
│ │
│ ├── workflows/ # Visual workflows
│ │ └── opportunity_approval.workflow.ts
│ │
│ ├── actions/ # Custom actions
│ │ └── send_proposal.action.ts
│ │
│ ├── api/ # Custom API endpoints
│ │ └── sync.endpoint.ts
│ │
│ ├── jobs/ # Background jobs
│ │ └── cleanup.job.ts
│ │
│ ├── config.schema.ts # Configuration schema (Zod)
│ │
│ └── lifecycle/ # Lifecycle hooks
│ ├── install.ts
│ ├── boot.ts
│ └── upgrade.ts
│
├── i18n/ # Translations
│ ├── en.json
│ ├── de.json
│ └── es.json
│
├── migrations/ # Database migrations
│ ├── 001_create_objects.ts
│ └── 002_add_indexes.ts
│
├── tests/ # Unit and integration tests
│ ├── objects/
│ ├── triggers/
│ └── integration/
│
├── assets/ # Static files (icons, images)
│ ├── icon.svg
│ └── screenshots/
│
└── dist/ # Compiled output (generated)Metadata Definitions
ObjectQL Schemas
Define database objects using ObjectQL schema syntax:
// src/objects/account.object.ts
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Account = ObjectSchema.create({
name: 'account',
label: 'Account',
pluralLabel: 'Accounts',
icon: 'building',
fields: {
name: Field.text({
label: 'Account Name',
required: true,
maxLength: 255,
}),
industry: Field.select({
label: 'Industry',
options: [
{ label: 'Technology', value: 'technology' },
{ label: 'Finance', value: 'finance' },
{ label: 'Healthcare', value: 'healthcare' },
],
}),
annual_revenue: Field.currency({
label: 'Annual Revenue',
scale: 2,
}),
primary_contact: Field.lookup('contact', {
label: 'Primary Contact',
}),
},
enable: {
trackHistory: true,
searchable: true,
apiEnabled: true,
},
});ObjectUI Views
Define user interfaces using ObjectUI layout DSL:
// src/views/account_list.view.ts
import { defineView } from '@objectstack/spec';
export default defineView({
name: 'account_list',
object: 'account',
type: 'list',
layout: {
type: 'grid',
columns: [
{ field: 'name', width: 200 },
{ field: 'industry', width: 150 },
{ field: 'annual_revenue', width: 150 },
{ field: 'primary_contact', width: 200 },
{ field: 'created_at', width: 150 },
],
filters: [
{ field: 'industry', operator: 'equals' },
{ field: 'annual_revenue', operator: 'greaterThan' },
],
actions: [
{ type: 'create', label: 'New Account' },
{ type: 'export', label: 'Export to CSV' },
],
},
});Triggers
Define business logic that executes on data changes. (Like definePlugin()
above, defineTrigger() is part of the proposed ergonomic authoring
surface — there is no such helper today. Record-triggered logic currently
lives in a lifecycle hook module under src/objects/<name>.hook.ts. Treat the
snippet below as design intent, not paste-ready code.)
// src/triggers/account_validation.trigger.ts (proposed shape)
import { defineTrigger } from '@objectstack/core';
export default defineTrigger({
name: 'account_validation',
object: 'account',
when: 'beforeInsert',
execute: async ({ record, context }) => {
// Validation: Annual revenue must be positive
if (record.annual_revenue < 0) {
throw new Error('Annual revenue cannot be negative');
}
// Auto-populate: Generate account number
if (!record.account_number) {
record.account_number = await generateAccountNumber(context);
}
// Enrichment: Fetch company data from external API
if (record.website) {
const companyData = await enrichCompanyData(record.website);
record.industry = record.industry || companyData.industry;
record.employee_count = companyData.employeeCount;
}
return record;
},
});Configuration Schema
Define plugin configuration with Zod for validation:
// src/config.schema.ts
import { z } from 'zod';
export const configSchema = z.object({
// API keys (marked as secret)
apiKey: z.string()
.describe('API Key for external service')
.meta({ secret: true }),
// Feature flags
enableOpportunityScoring: z.boolean()
.default(true)
.describe('Enable AI-powered opportunity scoring'),
// Limits
maxAccountsPerUser: z.number()
.min(1)
.max(10000)
.default(1000)
.describe('Maximum accounts a user can own'),
// URLs
webhookUrl: z.string()
.url()
.optional()
.describe('Webhook URL for account changes'),
// Enums
syncInterval: z.enum(['hourly', 'daily', 'weekly'])
.default('daily')
.describe('Data sync frequency'),
});
export type PluginConfig = z.infer<typeof configSchema>;Dependency Management
Dependency Types
1. Dependencies (Required)
Plugin cannot function without these.
dependencies: {
'@objectstack/core': '^2.0.0',
'@mycompany/base': '>=1.0.0 <2.0.0',
}2. Optional Dependencies
Plugin can function without these, but features are enhanced if present.
optionalDependencies: {
'@vendor/email': '^3.0.0',
}
// In plugin code
if (context.plugins.isInstalled('@vendor/email')) {
await email.send({ to: contact.email, subject: 'Welcome' });
}3. Peer Dependencies
Plugin expects consumer to install these (not bundled).
peerDependencies: {
'@objectstack/ui': '^2.0.0',
}Use for large dependencies (React, Vue) that should be shared across plugins.
Version Constraints
ObjectStack uses semantic versioning (semver) with these operators:
dependencies: {
// Exact version
'plugin-a': '1.0.0',
// Patch updates allowed (1.0.x)
'plugin-b': '~1.0.0',
// Minor updates allowed (1.x.x)
'plugin-c': '^1.0.0',
// Version range
'plugin-d': '>=1.0.0 <2.0.0',
// Latest version
'plugin-e': '*', // ⚠️ Not recommended for production
}Dependency Resolution
ObjectStack builds a dependency graph and loads plugins in topological order:
@objectstack/core (no deps)
↓
@mycompany/base (depends on core)
↓
@mycompany/crm (depends on base)
↓
@mycompany/sales (depends on crm)Load Order: core → base → crm → sales
Conflict Resolution: If two plugins require incompatible versions of the same dependency, installation fails with error:
Error: Dependency conflict
@mycompany/sales requires @objectstack/core@^2.0.0
@vendor/analytics requires @objectstack/core@^3.0.0
Cannot satisfy both constraints.
Solutions:
1. Upgrade @mycompany/sales to version compatible with core@3.x
2. Downgrade @vendor/analytics to version compatible with core@2.x
3. Contact plugin authors to update dependenciesLifecycle Hooks
Plugins can define hooks that execute at specific lifecycle events:
Hook Signatures
// src/lifecycle/install.ts
export async function onInstall({ context, transaction }) {
// Runs after plugin files are extracted, before metadata is registered
// Example: Create default data
await context.db.insert('crm_settings', {
maxAccountsPerUser: 1000,
enableScoring: true,
}, { transaction });
}
// src/lifecycle/uninstall.ts
export async function onUninstall({ context, transaction }) {
// Runs before plugin is removed
// Example: Archive plugin data instead of deleting
await context.db.update('account',
{ where: { plugin: 'crm' }},
{ archived: true },
{ transaction }
);
}
// src/lifecycle/enable.ts
export async function onEnable({ context }) {
// Runs when plugin is enabled (after install or manual enable)
// Example: Start background sync
await context.scheduler.create({
name: 'crm-sync',
schedule: '0 * * * *', // Every hour
handler: 'crm.sync',
});
}
// src/lifecycle/disable.ts
export async function onDisable({ context }) {
// Runs when plugin is disabled (before uninstall or manual disable)
// Example: Stop background jobs
await context.scheduler.pause('crm-sync');
}
// src/lifecycle/upgrade.ts
export async function onUpgrade({ context, fromVersion, toVersion, transaction }) {
// Runs during plugin upgrade
// Example: Data migration
if (fromVersion === '1.0.0' && toVersion === '2.0.0') {
await context.db.query(`
UPDATE account
SET account_number = CONCAT('ACC-', id::text)
WHERE account_number IS NULL
`, { transaction });
}
}
// src/lifecycle/boot.ts
export async function onBoot({ context }) {
// Runs every time ObjectStack boots (after plugin is loaded)
// Example: Validate configuration
const config = context.config.get('crm');
if (!config.apiKey) {
context.logger.warn('CRM API key not configured');
}
// Example: Register custom services
context.services.register('crm', new CRMService(config));
}Hook Execution Order
During installation:
1. onInstall
2. Register metadata (objects, views, etc.)
3. onEnable (if --enable flag)During upgrade:
1. onUpgrade
2. Apply migrations
3. Update metadataDuring boot:
1. Load all plugins
2. Resolve dependencies
3. onBoot (for each plugin in dependency order)
4. Start servicesPermissions
Plugins must declare permissions they require. ObjectStack enforces these at runtime.
System Permissions
permissions: {
system: [
// Network access
'network.http', // Make HTTP requests
'network.websocket', // Use WebSockets
// Storage access
'storage.database', // Query database
'storage.cache', // Use Redis/cache
'storage.filesystem', // Read/write files
// System capabilities
'system.cron', // Schedule jobs
'system.email', // Send emails
'system.events', // Publish/subscribe events
// Security
'security.encrypt', // Encrypt data
'security.sign', // Sign JWTs
],
}If plugin attempts operation without permission, ObjectStack throws error:
// Plugin tries to make HTTP request without permission
await fetch('https://api.example.com/data');
// Error: PermissionDenied
// Plugin @mycompany/crm does not have permission 'network.http'
// Add to plugin.manifest.ts:
// permissions: { system: ['network.http'] }Object Permissions
Declare which objects plugin creates and manages:
permissions: {
objects: [
'account', // Full control
'contact',
'opportunity',
],
}ObjectStack uses this for:
- Dependency tracking: Can't uninstall plugin if other plugins reference its objects
- Security: Plugin can only modify its own objects (not objects from other plugins)
- Cleanup: Uninstalling plugin can optionally remove its objects
Distribution Format
NPM Package
Plugins are distributed as NPM packages for easy versioning and distribution.
// package.json
{
"name": "@mycompany/crm",
"version": "1.5.0",
"description": "Customer Relationship Management",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "jest",
"lint": "eslint src/",
"package": "os plugin build"
},
"files": [
"dist/",
"objectstack.plugin.json",
"i18n/",
"assets/",
"README.md",
"LICENSE"
],
"dependencies": {
"@objectstack/core": "^2.0.0"
},
"devDependencies": {
"@objectstack/cli": "^2.0.0",
"typescript": "^5.0.0"
},
"keywords": [
"objectstack",
"objectstack-plugin",
"crm"
],
"repository": {
"type": "git",
"url": "https://github.com/mycompany/crm"
}
}Publishing to NPM
# Build plugin
npm run build
# Build the plugin artifact (validates manifest, bundles entry)
os plugin build
# Sign the artifact (separate step; emits a publisher signature)
os plugin sign crm-1.5.0.osplugin
# Publish to the ObjectStack package registry
os plugin publishPlugin Archive (.osplugin)
For air-gapped environments or marketplaces, plugins are packaged as .osplugin files:
# Create the .osplugin artifact (defaults to <id>-<version>.osplugin)
os plugin build --out crm-1.5.0.osplugin
# Install from a local artifact into a running runtime (air-gapped)
os package install ./crm-1.5.0.osplugin.osplugin format: Reproducible ustar+gzip tarball containing:
- Compiled entry (dist/index.mjs) and bundled assets
- Manifest (objectstack.plugin.json)
- Per-file content digests (
sha256-<base64>integrity map) - Signature (
SIGNATURE, added byos plugin sign)
Plugin Testing
The @objectstack/testing package and its createTestContext() /
createTestEnvironment() helpers shown below are proposed — there is no
such published package today. The snippets illustrate the intended test
ergonomics for the plugin spec, not a shipping API.
Unit Tests
// tests/triggers/account_validation.test.ts
import { createTestContext } from '@objectstack/testing';
import accountValidation from '../../src/triggers/account_validation';
describe('Account Validation Trigger', () => {
it('should reject negative revenue', async () => {
const context = createTestContext();
const record = { annual_revenue: -1000 };
await expect(
accountValidation.execute({ record, context })
).rejects.toThrow('Annual revenue cannot be negative');
});
it('should generate account number', async () => {
const context = createTestContext();
const record = { name: 'Acme Corp' };
const result = await accountValidation.execute({ record, context });
expect(result.account_number).toMatch(/^ACC-\d{6}$/);
});
});Integration Tests
// tests/integration/crm_workflow.test.ts
import { createTestEnvironment } from '@objectstack/testing';
describe('CRM Workflow', () => {
let env;
beforeAll(async () => {
env = await createTestEnvironment({
plugins: ['@mycompany/crm'],
});
});
it('should create account with contacts', async () => {
// Create account
const account = await env.objectQL.create('account', {
name: 'Test Corp',
industry: 'technology',
});
// Create contact
const contact = await env.objectQL.create('contact', {
first_name: 'John',
last_name: 'Doe',
account: account.id,
});
// Verify relationship
const accountWithContacts = await env.objectQL.findById('account', account.id, {
include: ['contacts'],
});
expect(accountWithContacts.contacts).toHaveLength(1);
expect(accountWithContacts.contacts[0].id).toBe(contact.id);
});
});Plugin Development Workflow
1. Scaffold Plugin
os create plugin crm
📁 Creating plugin: crm
📂 Location: packages/plugins/plugin-crm
✓ Created package.json
✓ Created tsconfig.json
✓ Created src/index.ts
✓ Created README.md
✅ Project created successfully!
Next steps:
cd packages/plugins/plugin-crm
pnpm install
pnpm build2. Develop Locally
cd packages/plugins/plugin-crm
# Watch mode (auto-rebuild on changes)
npm run dev
# In another terminal, run the dev server with hot-reload.
# Pass --ui to also serve the bundled Console portal at /_console/
os dev --ui3. Test Plugin
# Run unit tests
npm test
# Run linter
npm run lint
# Build the artifact (validates the manifest against ManifestSchema)
os plugin build4. Build & Publish
# Build production bundle
npm run build
# Build + sign the .osplugin artifact
os plugin build
os plugin sign crm-1.5.0.osplugin
# Publish to the ObjectStack package registry
os plugin publishBest Practices
1. Use Semantic Versioning Strictly
- Patch (1.0.x): Bug fixes, no breaking changes
- Minor (1.x.0): New features, backward compatible
- Major (x.0.0): Breaking changes
2. Document Breaking Changes
Always include migration guide in CHANGELOG.md for major versions.
## v2.0.0 (Breaking Changes)
### Removed
- `account.owner` field (use `account.owner_id` instead)
### Migration
The `onUpgrade` lifecycle hook runs automatically when the package is upgraded
to v2.0.0 and renames the field on existing records.3. Pin Core Dependencies
Use exact version for @objectstack/core to avoid surprises:
dependencies: {
'@objectstack/core': '2.0.0', // Not '^2.0.0'
}4. Validate Configuration Early
Use Zod schema to validate config during boot, not at runtime:
export async function onBoot({ context }) {
const config = context.config.get('crm', configSchema);
// Throws clear error if config is invalid
}5. Clean Up on Uninstall
Always provide onUninstall hook to clean up resources:
export async function onUninstall({ context, transaction }) {
// Stop jobs
await context.scheduler.delete('crm-sync', { transaction });
// Archive data (don't delete!)
await context.db.update('account',
{ plugin: 'crm' },
{ archived: true },
{ transaction }
);
}Summary
Plugin packages in ObjectStack:
- Manifest-driven:
plugin.manifest.tsis the source of truth - Self-contained: Bundle objects, views, logic, and config
- Dependency-managed: Semantic versioning with conflict detection
- Lifecycle-aware: Hooks for install, upgrade, boot, and uninstall
- NPM-compatible: Distribute via NPM or
.ospluginarchives
Next: Learn how configuration is resolved in Configuration Resolution.