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/core/src/types.ts (the runtime Plugin interface)
Import: import type { Plugin, PluginContext } from '@objectstack/core'


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/core @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 Package Manifest

A plugin package describes itself with a manifest validated by ManifestSchema. Create src/manifest.ts:

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

A runtime plugin's behaviour (services, hooks, objects) is declared in code via the Plugin interface — see Step 3. The manifest only describes package metadata.


Step 3: Implement the 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
      // `{ doc }` for inserts (`{ id, doc }` for updates) — mutate
      // `hookCtx.input.doc` in before* hooks to change the operation.
      const engine = ctx.getService<any>('data');
      engine.registerHook('beforeInsert', async (hookCtx: any) => {
        const doc = hookCtx.input.doc;
        if (doc?.first_name) {
          // Auto-generate a greeting field
          doc.welcome_message = greetingService.greet(doc.first_name as string);
        }
      }, { object: 'contact' }); // the engine only runs this hook for 'contact'
    }
  };
}

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: {
    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 }
    ]}
  }
});

Step 5: Write Tests

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: { doc: { first_name: 'Bob' } as Record<string, unknown> } };
    await hooks['beforeInsert'](hookCtx);
    expect(hookCtx.input.doc.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 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()
  ]
});

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.


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

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'

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', 'job'], // ADR-0020/0088: 'workflow' and 'trigger' are retired kinds
      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