| title | Plugin Development |
|---|---|
| description | Step-by-step guide to creating, testing, and publishing ObjectStack plugins |
This guide walks you through creating an ObjectStack plugin from scratch — from project setup to testing and registration.
**Source:** `packages/core/src/types.ts` (the runtime `Plugin` interface) **Import:** `import type { Plugin, PluginContext } from '@objectstack/core'`A plugin is a self-contained module that extends the ObjectStack kernel with:
- Services — Add new capabilities (email, payment, analytics)
- Hooks — React to lifecycle events (before/after record create, update, delete)
- Objects — Register new data objects and fields
- UI Components — Add custom views, widgets, or actions
- API Endpoints — Expose new REST endpoints
mkdir objectstack-plugin-hello
cd objectstack-plugin-hello
npm init -y
npm install @objectstack/core @objectstack/spec
npm install -D typescript vitest @types/nodeCreate tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}A plugin package describes itself with a manifest validated by ManifestSchema. Create src/manifest.ts:
{/* os:check */}
import { ManifestSchema } from '@objectstack/spec/kernel';
export const manifest = ManifestSchema.parse({
id: 'com.example.hello', // required — reverse-domain package id
name: 'objectstack-plugin-hello', // required — human-readable name
version: '1.0.0',
description: 'A simple example plugin that adds a greeting service.',
type: 'plugin',
});Create src/index.ts. A runtime plugin implements the Plugin interface: services are registered inside init(ctx) via ctx.registerService. Kernel lifecycle hooks (kernel:ready, custom events) are wired with ctx.hook(...); record lifecycle hooks (beforeInsert, afterUpdate, …) are registered on the data engine — see Events & Hooks for the two hook systems.
import type { Plugin, PluginContext } from '@objectstack/core';
export interface GreetingService {
greet(name: string): string;
}
export function createHelloPlugin(): Plugin {
const greetingService: GreetingService = {
greet(name: string): string {
return `Hello, ${name}! Welcome to ObjectStack.`;
}
};
return {
name: 'hello_world',
version: '1.0.0',
// Ensure the data engine plugin initializes before this one
dependencies: ['com.objectstack.engine.objectql'],
init(ctx: PluginContext) {
// Register services so other plugins can consume them
ctx.registerService('greeting', greetingService);
// Hook into the record lifecycle via the data engine.
// Data hooks receive a single HookContext. The input shape is
// `{ data }` for inserts (`{ id, data }` for updates) — mutate
// `hookCtx.input.data` in before* hooks to change the operation.
const engine = ctx.getService<any>('data');
engine.registerHook('beforeInsert', async (hookCtx: any) => {
const data = hookCtx.input.data;
if (data?.first_name) {
// Auto-generate a greeting field
data.welcome_message = greetingService.greet(data.first_name as string);
}
}, { object: 'contact' }); // the engine only runs this hook for 'contact'
}
};
}Plugins can register new objects:
{/* os:check */}
import { ObjectSchema } from '@objectstack/spec/data';
export const greetingLogObject = ObjectSchema.create({
name: 'greeting_log',
label: 'Greeting Log',
fields: {
recipient: { label: 'Recipient', type: 'text', required: true },
message: { label: 'Message', type: 'text', required: true },
sent_at: { label: 'Sent At', type: 'datetime' },
channel: { label: 'Channel', type: 'select', options: [
{ label: 'Email', value: 'email' },
{ label: 'SMS', value: 'sms' },
{ label: 'In-App', value: 'in_app', default: true }
]}
}
});Create src/index.test.ts:
import { describe, it, expect } from 'vitest';
import type { PluginContext } from '@objectstack/core';
import { createHelloPlugin } from './index';
// Minimal fake PluginContext with a stub data engine that captures hooks.
function createFakeContext() {
const services: Record<string, unknown> = {};
const hooks: Record<string, (hookCtx: any) => void | Promise<void>> = {};
const hookOptions: Record<string, unknown> = {};
const engine = {
registerHook: (event: string, handler: (hookCtx: any) => void, options?: unknown) => {
hooks[event] = handler;
hookOptions[event] = options;
},
};
const ctx = {
registerService: (name: string, service: unknown) => { services[name] = service; },
getService: (name: string) => (name === 'data' ? engine : services[name]),
} as unknown as PluginContext;
return { ctx, services, hooks, hookOptions };
}
describe('HelloPlugin', () => {
it('should have the correct name', () => {
expect(createHelloPlugin().name).toBe('hello_world');
});
it('should register the greeting service', () => {
const { ctx, services } = createFakeContext();
createHelloPlugin().init(ctx);
expect(services).toHaveProperty('greeting');
});
it('should greet by name', () => {
const { ctx, services } = createFakeContext();
createHelloPlugin().init(ctx);
const greeting = services.greeting as { greet: (name: string) => string };
expect(greeting.greet('Alice')).toBe('Hello, Alice! Welcome to ObjectStack.');
});
it('should add welcome message on contact create', async () => {
const { ctx, hooks } = createFakeContext();
createHelloPlugin().init(ctx);
const hookCtx = { object: 'contact', input: { data: { first_name: 'Bob' } as Record<string, unknown> } };
await hooks['beforeInsert'](hookCtx);
expect(hookCtx.input.data.welcome_message).toBe('Hello, Bob! Welcome to ObjectStack.');
});
it('should scope the hook to the contact object', () => {
// The engine (not the handler) filters by object — assert the option
const { ctx, hookOptions } = createFakeContext();
createHelloPlugin().init(ctx);
expect(hookOptions['beforeInsert']).toEqual({ object: 'contact' });
});
});Run tests:
npx vitest runIn your application's objectstack.config.ts:
import { defineStack } from '@objectstack/spec';
import { createHelloPlugin } from 'objectstack-plugin-hello';
export default defineStack({
objects: [/* your objects */],
plugins: [
createHelloPlugin()
]
});The kernel loads plugins in dependency order, calling each plugin's init(ctx) first (where services and hooks are registered) and then start(ctx) once all plugins have initialized.
| Item | Convention | Example |
|---|---|---|
| Plugin name | snake_case |
hello_world |
| Service name | snake_case |
greeting |
| Object names | snake_case |
greeting_log |
| npm package | kebab-case |
objectstack-plugin-hello |
Declare the capabilities your plugin needs in its manifest's permissions block
(the structured PluginPermissionsSchema, ADR-0025 §3.2). Request only the minimum:
| Grant | Description |
|---|---|
services |
Platform services the plugin may resolve (e.g. object, http) |
hooks |
Lifecycle hooks the plugin may register (e.g. record.beforeInsert) |
network |
Network hosts the plugin may reach (e.g. api.acme.com) |
fs |
Filesystem paths the plugin may access |
Always use structured errors:
{/* os:check */}
import type { EnhancedApiError } from '@objectstack/spec/api';
function handlePluginError(error: unknown): EnhancedApiError {
return {
code: 'INTERNAL_ERROR',
message: `Hello plugin error: ${String(error)}`,
category: 'server',
httpStatus: 500,
retryable: false
};
}- [ ] Manifest validates against
ManifestSchema - [ ] Services register correctly
- [ ] Hooks fire on expected events
- [ ] Custom objects validate against
ObjectSchema - [ ] Error cases are handled gracefully
- [ ] No side effects in service constructors
objectstack-plugin-hello/
├── src/
│ ├── index.ts # Plugin entry point
│ ├── index.test.ts # Tests
│ ├── manifest.ts # Plugin manifest
│ └── services/
│ └── greeting.ts # Service implementation
├── package.json
├── tsconfig.json
└── README.md
For plugins that extend the ObjectStack Studio IDE, use defineStudioPlugin to declare UI contribution points — metadata viewers, sidebar groups, toolbar actions, panels, and commands. This follows a VS Code-like extension model.
{/* os:check */}
import { defineStudioPlugin } from '@objectstack/spec/studio';
export const manifest = defineStudioPlugin({
id: 'mycompany.crm-designer',
name: 'CRM Designer',
version: '1.0.0',
description: 'Custom object designer for CRM modules',
author: 'Your Name',
contributes: {
metadataViewers: [{
id: 'crm-object-explorer',
metadataTypes: ['object', 'objects'],
label: 'CRM Object Explorer',
priority: 100,
modes: ['preview', 'design', 'data'],
}],
},
});Plugin IDs use reverse-domain notation and must match the pattern ^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$:
{/* os:check */}
// ✅ Valid IDs
'objectstack.flow-designer'
'mycompany.crm-tools'
'acme.billing-plugin'
// ❌ Invalid IDs
'MyPlugin' // uppercase
'my plugin' // spaces
'my_plugin' // underscores (use hyphens)Studio plugins can declare six types of contributions:
| Contribution | Purpose | Example |
|---|---|---|
metadataViewers |
Custom viewers/designers for metadata types | Object explorer, Flow canvas |
sidebarGroups |
Sidebar navigation groups | "CRM Objects", "Automation" |
actions |
Toolbar, context menu, and command palette actions | "Deploy", "Validate Schema" |
metadataIcons |
Icons and labels for metadata types | Database icon for objects |
panels |
Auxiliary panels (bottom, right, modal) | Output log, Problems panel |
commands |
Command palette entries with keyboard shortcuts | "Open Settings" (Ctrl+,) |
{/* os:check */}
import { defineStudioPlugin } from '@objectstack/spec/studio';
export const manifest = defineStudioPlugin({
id: 'objectstack.flow-designer',
name: 'Flow Designer',
version: '2.0.0',
description: 'Visual flow builder for automation workflows',
contributes: {
metadataViewers: [{
id: 'flow-canvas',
metadataTypes: ['flow', 'flows'],
label: 'Flow Canvas',
priority: 100,
modes: ['design', 'code'],
}],
sidebarGroups: [{
key: 'automation',
label: 'Automation',
icon: 'workflow',
metadataTypes: ['flow', 'job'], // ADR-0088: 'trigger' is a retired kind ('flow' and 'job' are canonical)
order: 30,
}],
actions: [{
id: 'validate-flow',
label: 'Validate Flow',
icon: 'check-circle',
location: 'toolbar',
metadataTypes: ['flow'],
}],
metadataIcons: [{
metadataType: 'flow',
label: 'Flow',
icon: 'git-branch',
}],
panels: [{
id: 'flow-debug',
label: 'Flow Debugger',
icon: 'bug',
location: 'bottom',
}],
commands: [{
id: 'objectstack.flow-designer.run',
label: 'Run Flow',
shortcut: 'Ctrl+Shift+R',
icon: 'play',
}],
},
});Every Studio plugin loads and activates immediately on registration — activate()
runs at registration time, unconditionally. The former activationEvents manifest
key was removed in v17 (#4657, ADR-0049): it declared lazy activation that no
Studio host ever implemented, so a manifest that still carries it now fails the
parse with the upgrade prescription. Delete the key.
Metadata viewers declare which modes they support:
| Mode | Description |
|---|---|
preview |
Read-only rendered preview |
design |
Visual drag-and-drop designer |
code |
Raw schema/code editor |
data |
Live data browser |
history |
Version/change history view |
- Common Patterns — See how plugins fit into the application architecture
- Error Catalog — Return proper error codes from your plugin
- Field Type Gallery — Define custom objects with the right field types