ObjectStackObjectStack

Plugin Development

Step-by-step guide to creating, testing, and publishing ObjectStack plugins

Plugin Development Tutorial

This guide walks you through creating an ObjectStack plugin from scratch — from project setup to testing and registration.

Source: packages/spec/src/kernel/plugin.zod.ts
Import: import { PluginManifestSchema, PluginHookSchema } from '@objectstack/spec/kernel'


What is a Plugin?

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 or GraphQL endpoints

Step 1: Create the Plugin Project

mkdir objectstack-plugin-hello
cd objectstack-plugin-hello
npm init -y
npm install @objectstack/spec
npm install -D typescript vitest @types/node

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

Step 2: Define the Plugin Manifest

Create src/manifest.ts:

import { defineStudioPlugin } from '@objectstack/spec';

export const manifest = defineStudioPlugin({
  name: 'hello_world',
  label: 'Hello World Plugin',
  version: '1.0.0',
  description: 'A simple example plugin that adds a greeting service.',
  author: {
    name: 'Your Name',
    email: 'you@example.com'
  },
  compatibility: {
    minVersion: '3.0.0'
  },
  permissions: [
    'object:read',
    'object:write'
  ],
  services: ['greeting'],
  hooks: ['before_record_create']
});

Step 3: Implement the Plugin

Create src/index.ts:

import type { PluginManifest } from '@objectstack/spec/kernel';

export interface GreetingService {
  greet(name: string): string;
}

export function createHelloPlugin() {
  const greetingService: GreetingService = {
    greet(name: string): string {
      return `Hello, ${name}! Welcome to ObjectStack.`;
    }
  };

  return {
    name: 'hello_world',

    // Register services with the kernel
    onRegister(kernel: { registerService: (name: string, service: unknown) => void }) {
      kernel.registerService('greeting', greetingService);
    },

    // Hook into record lifecycle
    hooks: {
      before_record_create(context: { object: string; data: Record<string, unknown> }) {
        if (context.object === 'contact' && context.data.first_name) {
          // Auto-generate a greeting field
          context.data.welcome_message = greetingService.greet(
            context.data.first_name as string
          );
        }
        return context;
      }
    }
  };
}

Step 4: Add Custom Objects (Optional)

Plugins can register new objects:

import { ObjectSchema } from '@objectstack/spec/data';

export const greetingLogObject = ObjectSchema.create({
  name: 'greeting_log',
  label: 'Greeting Log',
  fields: [
    { name: 'recipient', label: 'Recipient', type: 'text', required: true },
    { name: 'message', label: 'Message', type: 'text', required: true },
    { name: 'sent_at', label: 'Sent At', type: 'datetime' },
    { name: 'channel', label: 'Channel', type: 'select', options: [
      { label: 'Email', value: 'email' },
      { label: 'SMS', value: 'sms' },
      { label: 'In-App', value: 'in_app', default: true }
    ]}
  ]
});

Step 5: Write Tests

Create src/index.test.ts:

import { describe, it, expect } from 'vitest';
import { createHelloPlugin } from './index';

describe('HelloPlugin', () => {
  const plugin = createHelloPlugin();

  it('should have the correct name', () => {
    expect(plugin.name).toBe('hello_world');
  });

  it('should register the greeting service', () => {
    const services: Record<string, unknown> = {};
    plugin.onRegister({
      registerService: (name, service) => { services[name] = service; }
    });
    expect(services).toHaveProperty('greeting');
  });

  it('should greet by name', () => {
    const services: Record<string, unknown> = {};
    plugin.onRegister({
      registerService: (name, service) => { services[name] = service; }
    });
    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', () => {
    const context = {
      object: 'contact',
      data: { first_name: 'Bob' } as Record<string, unknown>
    };
    const result = plugin.hooks.before_record_create(context);
    expect(result.data.welcome_message).toBe('Hello, Bob! Welcome to ObjectStack.');
  });

  it('should not modify non-contact objects', () => {
    const context = {
      object: 'task',
      data: { title: 'Test' } as Record<string, unknown>
    };
    const result = plugin.hooks.before_record_create(context);
    expect(result.data).not.toHaveProperty('welcome_message');
  });
});

Run tests:

npx vitest run

Step 6: Register with the Kernel

In 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()
  ]
});

Plugin Best Practices

Naming Conventions

ItemConventionExample
Plugin namesnake_casehello_world
Service namesnake_casegreeting
Object namessnake_casegreeting_log
npm packagekebab-caseobjectstack-plugin-hello

Permission Model

Only request the minimum permissions your plugin needs:

PermissionDescription
object:readRead records from objects
object:writeCreate, update, delete records
object:adminCreate/modify object schemas
system:readRead system configuration
system:adminModify system configuration

Error Handling

Always use structured errors:

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
  };
}

Testing Checklist

  • Manifest validates against PluginManifestSchema
  • Services register correctly
  • Hooks fire on expected events
  • Custom objects validate against ObjectSchema
  • Error cases are handled gracefully
  • No side effects in service constructors

Plugin Directory Structure

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

Studio Plugin Manifests with defineStudioPlugin

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.

Source: packages/spec/src/studio/plugin.zod.ts
Import: import { defineStudioPlugin } from '@objectstack/spec/studio'
Alt Import: import { Studio } from '@objectstack/spec'

Basic Studio Plugin

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 ID Format

Plugin IDs use reverse-domain notation and must match the pattern ^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$:

// ✅ Valid IDs
'objectstack.flow-designer'
'mycompany.crm-tools'
'acme.billing-plugin'

// ❌ Invalid IDs
'MyPlugin'         // uppercase
'my plugin'        // spaces
'my_plugin'        // underscores (use hyphens)

Contribution Points

Studio plugins can declare six types of contributions:

ContributionPurposeExample
metadataViewersCustom viewers/designers for metadata typesObject explorer, Flow canvas
sidebarGroupsSidebar navigation groups"CRM Objects", "Automation"
actionsToolbar, context menu, and command palette actions"Deploy", "Validate Schema"
metadataIconsIcons and labels for metadata typesDatabase icon for objects
panelsAuxiliary panels (bottom, right, modal)Output log, Problems panel
commandsCommand palette entries with keyboard shortcuts"Open Settings" (Ctrl+,)

Full Example with All Contributions

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',
  activationEvents: ['onMetadataType:flow'],

  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', 'trigger', 'workflow'],
      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',
    }],
  },
});

Activation Events

Control when your plugin loads with activation events:

PatternTrigger
*Activate immediately (eager, default)
onMetadataType:objectWhen metadata type "object" is loaded
onCommand:myPlugin.doSomethingWhen command is invoked
onView:myPlugin.myPanelWhen panel is opened

View Modes

Metadata viewers declare which modes they support:

ModeDescription
previewRead-only rendered preview
designVisual drag-and-drop designer
codeRaw schema/code editor
dataLive data browser

Next Steps

On this page