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 — an ergonomic manifest wrapper, semantic-version dependency
resolution, provider/consumer contracts. 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/
src/manifest.ts ← TypeScript source: ManifestSchema.parse({ … })
objectstack.config.ts ← stack entry: defineStack({ manifest, objects, … })
objectstack.plugin.json ← distributable manifest read by `os plugin build`There is no plugin.manifest.ts / .yml / .json — that filename appears nowhere in
the implementation. The shape is always ManifestSchema from @objectstack/spec/kernel.
os plugin build reads the JSON form (objectstack.plugin.json, exported as
MANIFEST_FILENAME from packages/cli/src/utils/osplugin.ts) and validates it with
ManifestSchema.safeParse; TypeScript authoring runs the same object through
ManifestSchema.parse(...) and hands it to defineStack({ manifest }).
Manifest Schema
// src/manifest.ts — proposed authoring wrapper (see the callout below for the
// fields ManifestSchema actually declares today)
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',
},
// NOTE: there is no `lifecycle` file-map. Earlier revisions of this page
// showed one (`onInstall: 'src/lifecycle/install.ts'`, …) — ManifestSchema
// never declared it and nothing ever loaded those files (#4212). Runtime
// behaviour lives on the plugin class (`init`/`start`/`destroy`, below).
// PERMISSIONS — proposed shape only. The real block has no `system` or
// `objects` key; see "Permissions" below for what ManifestSchema accepts.
permissions: {
system: [
'network.http',
'storage.database',
'storage.cache',
],
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: {
minObjectStackVersion: '17.0.0',
maxObjectStackVersion: '18.0.0',
nodeVersion: '>=22.0.0',
},
},
});What ManifestSchema actually declares today
(packages/spec/src/kernel/manifest.zod.ts): id, version, type and name are
required; the optional fields are namespace, defaultDatasource, scope,
description, permissions, objects, datasources, dependencies,
configuration, contributes, data, capabilities, extensions,
navigationContributions, loading, engine, engines, runtime, packaging
and integrity.
The displayName / author / license / homepage / optionalDependencies /
peerDependencies / metadata / config / marketplace keys above are
proposal-only — the schema declares none of them. Notably: compatibility ranges
live in engines: { platform, protocol } (or the legacy engine: { objectstack }),
not marketplace.compatibility; config defaults live in
configuration: { title, properties } (a simplified JSON-Schema map with a per-key
secret flag), not config.defaults / config.secrets; and metadata globs are the
top-level objects / datasources arrays, not a metadata block.
Directory Structure
A well-organized plugin follows this standard structure:
@mycompany/crm/
├── objectstack.plugin.json # Plugin manifest — ManifestSchema (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)
│ # (no src/lifecycle/ — the install/boot/upgrade
│ # hook family was retired, #4212)
│
├── 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';
// `defineView` takes a view *container* keyed by slot (`list`, `form`,
// `listViews`, `formViews`). A flat `{ name, object, type, layout }` object is
// rejected at build time. The target object goes under `data` (provider
// `object`), and `type` is one of grid | kanban | gallery | calendar |
// timeline | gantt | map | chart | tree — a data table is `grid` (there is no
// `list` type).
export default defineView({
list: {
type: 'grid',
data: { provider: 'object', object: 'account' },
columns: [
{ field: 'name', width: 200 },
{ field: 'industry', width: 150 },
{ field: 'annual_revenue', width: 150 },
{ field: 'primary_contact', width: 200 },
{ field: 'created_at', width: 150 },
],
},
});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
Only dependencies exists in ManifestSchema today (a
Record<packageId, versionRange>). optionalDependencies and peerDependencies
below are proposal-only — the schema declares neither, so nothing resolves them.
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 — `PluginContext` (packages/core/src/types.ts) has no
// `plugins` namespace and no `isInstalled()`. Probe the service registry:
if (ctx.getServices().has('email')) {
const email = ctx.getService<EmailService>('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). SemanticVersionManager.satisfies()
(packages/core/src/dependency-resolver.ts) accepts exactly these forms:
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',
// Single bound: >= , > , <= , <
'plugin-d': '>=1.0.0',
// Inclusive range — the hyphen form is the ONLY two-bound form the
// resolver parses. A space-separated `>=1.0.0 <2.0.0` is not supported:
// `parse()` anchors its semver regex, so the trailing bound throws
// "Invalid semantic version".
'plugin-e': '1.0.0 - 2.0.0',
// Latest version (`*` and `latest` both match everything)
'plugin-f': '*', // ⚠️ 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:
When two plugins require incompatible versions of the same dependency,
DependencyResolver.detectConflicts() reports it as a structured
DependencyConflict record rather than a formatted CLI banner:
{
type: 'version-mismatch', // | missing-dependency | circular-dependency
// | incompatible-versions | conflicting-interfaces
severity: 'error',
description: 'Version mismatch for @objectstack/core: detected 1 unsatisfied requirements',
plugins: [
{ pluginId: '@objectstack/core', version: '2.4.0' },
{ pluginId: '@vendor/analytics', version: '^3.0.0' },
],
resolutions: [
// strategy: upgrade | downgrade | replace | disable | manual
{ strategy: 'upgrade', description: 'Upgrade @objectstack/core to satisfy all constraints' },
],
}Circular dependencies are reported separately with severity: 'critical'; the kernel
also throws on them directly during bootstrap() ([Kernel] Circular dependency detected: …).
Runtime Contract
A runtime plugin implements exactly three lifecycle methods
(packages/core/src/types.ts); init is required, the rest optional:
export class CRMPlugin implements Plugin {
name = 'plugin.crm';
version = '2.0.0';
// Phase 1 — sequential, dependency-topological order. Register services,
// schemas, routes. Other plugins' services may not exist yet.
async init(ctx: PluginContext) {
ctx.registerService('crm', new CRMService());
}
// Phase 2 — after every plugin's init. All services resolvable; begin work.
async start(ctx: PluginContext) {
ctx.logger.info('CRM sync running');
}
// Shutdown (reverse registration order), and rollback when a later
// plugin's start() fails.
async destroy() {
/* stop timers, close connections */
}
}A plugin may also expose healthCheck(), which the kernel invokes on demand
(kernel.checkPluginHealth) rather than at boot.
Execution Order
During boot (kernel.bootstrap()):
1. use(plugin) — validate structure + version, store (no code runs)
2. init — every plugin, sequentially, in dependency-topological
order (kernel.resolveDependencies() walks each plugin's
`dependencies`; registration order when none declared)
3. start — same order, every plugin that defines it
4. kernel:ready → kernel:bootstrapped → kernel:listening eventsDuring shutdown:
1. kernel:shutdown event
2. destroy — every plugin, REVERSE registration orderDuring installation / uninstallation / upgrade — no plugin code runs.
A package is metadata: install validates the manifest and signature, records
the package (registry.installPackage() → sys_packages), hot-registers
metadata and syncs schemas; upgrade applies metadata migrations (ADR-0087);
uninstall removes the registration. The one authored-code seam an app bundle
has is its module-level onEnable export, invoked by AppPlugin.start() at
boot — not at install.
Earlier revisions of this page documented an onInstall / onEnable /
onDisable / onUninstall / onUpgrade / onBoot hook family loaded from a
manifest lifecycle file-map. That file-map never existed — ManifestSchema
has no lifecycle key and the kernel never invoked any of these hooks, so code
written against them silently never ran. PluginLifecycleSchema
(packages/spec/src/kernel/plugin.zod.ts) still declares onInstall,
onEnable, onDisable, onUninstall and onUpgrade as optional functions, but
nothing calls them; onBoot appears nowhere in the implementation at all. The
single live exception is the module-level onEnable export of an app bundle
described above, which AppPlugin.start() calls at boot — that is a stack-entry
export, not a manifest hook. The family was retired from the protocol
(#4212, ADR-0049 enforce-or-remove).
Permissions
Plugins declare permissions in the manifest. The declaration is the install-time consent request; what the runtime enforces is the granted set.
Declared Permissions
ManifestSchema.permissions accepts either the legacy flat string[] or the structured
PluginPermissionsSchema block — four keys, and no system list
(packages/spec/src/kernel/manifest.zod.ts):
permissions: {
services: ['object', 'http'], // services the plugin may resolve
hooks: ['record.beforeInsert'], // lifecycle hooks it may register
network: ['api.acme.com'], // hosts it may reach
fs: [], // filesystem paths it may access
}Dotted capability strings like network.http, network.websocket and
storage.database are values of ResourceTypeSchema in the plugin-sandbox permission
descriptor (packages/spec/src/kernel/plugin-security-advanced.zod.ts) — they are not
manifest keys. storage.cache, storage.filesystem, system.cron, system.email,
system.events, security.encrypt and security.sign do not exist anywhere in the
implementation.
Enforcement is narrower than a sandbox. PluginPermissionEnforcer
(packages/core/src/security/plugin-permission-enforcer.ts) derives
canAccessService / canTriggerHook / canReadFile / canWriteFile /
canNetworkRequest from the granted set, but only service access is wired into a
live code path: SecurePluginContext.getService() and .replaceService() call
enforceServiceAccess. enforceNetworkRequest, enforceFileRead and enforceFileWrite
have no call sites outside the enforcer module itself, and nothing patches global
fetch — a plugin that calls
fetch() directly is not intercepted today. Treat network / fs declarations as
consent metadata, not a runtime jail.
Declaring Objects
A package declares the objects it ships through the manifest's top-level objects glob
list — ManifestSchema has no permissions.objects key:
objects: [
'./src/objects/*.object.ts',
]Ownership is tracked by the package registry rather than by a permission declaration:
install records the package and its metadata (registry.installPackage() →
sys_packages), and uninstall unregisters it and runs the data-plane cleanups domain
plugins registered via protocol.registerUninstallCleanup(name, cleanup) — that is how
plugin-security revokes its package-owned permission sets. There is no runtime rule
preventing one plugin from writing to another plugin's 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 detached .sig publisher signature)
os plugin sign crm-1.5.0.osplugin --key ./publisher.key.pem
# 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
# Air-gapped install: `os package install` reads a compiled JSON artifact
# (e.g. ./dist/objectstack.json) into a running runtime — it does not ingest the
# .osplugin tarball, which is uploaded to the marketplace via `os plugin publish`.
os package install ./dist/objectstack.json.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 placeholder (
SIGNATURE, written byos plugin build); the real publisher signature is a detached<id>-<version>.osplugin.sigsidecar produced byos plugin sign(signing never modifies the artifact bytes)
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 --key ./publisher.key.pem
# 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
Ship a metadata migration with the upgrade (ADR-0087) that renames the field on
existing records. There is no `onUpgrade` hook to hang this on: the schema
declares one (`PluginLifecycleSchema`) but nothing in the kernel calls it.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
Parse config with your Zod schema in init() — the first phase that runs — so a bad
value fails the boot instead of the first request. There is no onBoot hook (the name
appears nowhere in the implementation), and PluginContext has no config member:
export class CRMPlugin implements Plugin {
name = 'plugin.crm';
constructor(private readonly options: unknown) {}
async init(ctx: PluginContext) {
// Throws a clear error before anything starts, not on the first request
const config = configSchema.parse(this.options);
ctx.registerService('crm', new CRMService(config));
}
}5. Release Resources in destroy()
destroy() is the only cleanup seam the kernel invokes — on shutdown (reverse
registration order) and on rollback when a later plugin's start() fails. There is no
onUninstall hook: PluginLifecycleSchema declares one but nothing calls it, and
PluginContext exposes no db or scheduler member.
async destroy() {
clearInterval(this.syncTimer);
await this.connection.close();
}For data-plane cleanup at package-uninstall time, register a named cleanup with the
metadata protocol instead — protocol.registerUninstallCleanup('crm-archive', fn), which
protocol.deletePackage() runs and whose outcome rides on the response.
Summary
Plugin packages in ObjectStack:
- Manifest-driven:
ManifestSchemais the source of truth (objectstack.plugin.jsoninside a built artifact) - Self-contained: Bundle objects, views, logic, and config
- Dependency-managed: Semantic versioning with conflict detection
- Lifecycle-aware:
init→start→destroyon the plugin class — no install/upgrade/boot/uninstall hooks - NPM-compatible: Distribute via NPM or
.ospluginarchives
Next: Learn how configuration is resolved in Configuration Resolution.