
Objectstack Platform
- 124 installs
- 18 repo stars
- Updated August 5, 2026
- objectstack-ai/framework
Helps with ai & agent building tasks.
About
objectstack-platform is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- objectstack-platform
- AI & Agent Building
- AI-coding skill
Objectstack Platform by the numbers
- 124 all-time installs (skills.sh)
- +4 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #3,744 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/objectstack-ai/framework --skill objectstack-platformAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 124 |
|---|---|
| repo stars | ★ 18 |
| Last updated | August 5, 2026 |
| Repository | objectstack-ai/framework ↗ |
What it does
Helps with ai & agent building tasks.
Files
Platform — ObjectStack Bootstrap & Plugin System
Expert instructions for two related concerns:
1. Project setup — scaffolding new projects, writing objectstack.config.ts, picking drivers and adapters, the runtime boot sequence (the original "quickstart" skill). 2. Plugin development — building plugins, registering services, wiring kernel hook / event handlers, working with ObjectKernel vs LiteKernel (the original "plugin" skill).
Both areas share the same defineStack() / kernel surface, which is why they live in one skill.
---
When to Use This Skill
- Creating a new ObjectStack project from scratch.
- Choosing the right project template (minimal-api, full-stack, plugin).
- Writing or modifying `objectstack.config.ts` (
defineStack()config). - Selecting a database driver (Memory, SQL, Turso).
- Integrating with a web framework (Hono, Express, Fastify, Next.js, etc.).
- Understanding the runtime boot sequence and plugin loading order.
- Setting up multi-app composition with
composeStacks(). - Answering "how do I get started?" questions.
---
Decision Tree: Choosing a Template
What are you building?
│
├── A simple REST API or backend service?
│ └── ✅ minimal-api
│ • 1 object, REST endpoints, in-memory driver
│ • Fastest path to a running API
│
├── A full business application with UI?
│ └── ✅ full-stack
│ • Multiple objects, views, apps, auth
│ • Studio UI included
│ • CRM-like starter with relationships
│
└── A reusable extension for other projects?
└── ✅ plugin
• Plugin scaffold with onInstall/onEnable/onDisable
• Exports objects that other apps can import
• Designed for the marketplaceScaffolding Command
# Interactive — prompts for name, template, package manager
npx create-objectstack
# Direct — skip prompts
npx create-objectstack my-app --template full-stackTemplates: minimal-api | full-stack | plugin
---
Project Structure Conventions
Every ObjectStack project follows this directory structure:
my-app/
├── objectstack.config.ts # ← THE entry point — defineStack()
├── package.json
├── tsconfig.json
└── src/
├── objects/ # Business object definitions
│ ├── task.object.ts # → exports a single object
│ └── index.ts # → barrel: export * from './task.object'
├── views/ # Optional: UI view definitions
│ ├── task.view.ts
│ └── index.ts
├── apps/ # Optional: app definitions (nav, pages)
│ ├── main.app.ts
│ └── index.ts
├── flows/ # Optional: automation flows
│ ├── task.flow.ts
│ └── index.ts
├── actions/ # Optional: action definitions
│ ├── task.action.ts
│ └── index.ts
├── dashboards/ # Optional: dashboards
├── reports/ # Optional: reports
├── i18n/ # Optional: translation bundles
└── handlers/ # Optional: runtime hook handlersNaming Conventions
| Concept | Convention | Example |
|---|---|---|
| File names | {name}.{type}.ts | task.object.ts, main.app.ts |
| Machine names | snake_case | project_task, first_name |
| Config keys | camelCase | maxLength, defaultValue |
| Barrel exports | Object.values(imported) | objects: Object.values(objects) |
---
CRM Blueprint (Reference Implementation)
When scaffolding a production-style metadata app, align with this CRM-style layout:
| Blueprint Area | CRM Reference | What to Reuse |
|---|---|---|
| Stack assembly | objectstack.config.ts | Single defineStack() root aggregating all metadata collections |
| By-type directories | src/{objects,views,pages,actions,flows,...} | Domain-per-folder layout with barrel exports |
| Typed aggregates | src/*/index.ts | Export allFlows / allAgents / allSkills typed arrays |
| Runtime capabilities | requires: ['ai','automation','analytics','auth','ui','approvals','sharing'] | Declare opt-in capabilities explicitly |
| Security assembly | src/profiles/* + src/sharing/* | Compose permissions, sharingRules, and roles in stack root |
| Localization assembly | src/translations/* + i18n | Keep per-locale files and central bundle registration |
Use this as the default template for “metadata application” requests before simplifying to minimal-api.
---
defineStack() — The Core Configuration
objectstack.config.ts is the single entry point for every project. It calls defineStack() to declare all metadata.
Minimal Example
import { defineStack, Data } from '@objectstack/spec';
const { Field } = Data;
export default defineStack({
manifest: {
id: 'com.example.todo',
version: '1.0.0',
type: 'app',
name: 'Todo Manager',
},
objects: [
{
name: 'task',
label: 'Task',
fields: {
title: Field.text({ required: true }),
status: Field.select({ options: [
{ label: 'Open', value: 'open' },
{ label: 'Done', value: 'done' },
], defaultValue: 'open' }),
due_date: Field.date(),
},
},
],
});Full Configuration Reference
defineStack() accepts an ObjectStackDefinitionInput. Each top-level key holds a collection of one metadata kind — objects, views, apps, pages, dashboards, reports, actions, flows, workflows, approvals, agents, ragPipelines, hooks, apis, webhooks, roles, permissions, sharingRules, policies, themes, translations, i18n, datasources, data (seed), plugins, devPlugins, manifest, objectExtensions, mappings, analyticsCubes, connectors.
For the exact Zod shape — including which keys are optional and what types the collection items take — read node_modules/@objectstack/spec/src/stack.zod.ts (ObjectStackDefinitionInputSchema). Each collection's item shape lives in its own domain folder (data/object.zod.ts, ui/view.zod.ts, …).
Map Format (Key → Name)
All named collections support map format where the key becomes the name field:
export default defineStack({
// Array format (traditional)
objects: [
{ name: 'task', fields: { title: Field.text() } },
],
// Map format (key becomes name) — preferred for readability
objects: {
task: { fields: { title: Field.text() } },
project: { fields: { name: Field.text() } },
},
});Barrel Import Pattern
Use barrel exports to keep config clean:
// src/objects/index.ts
export { default as task } from './task.object';
export { default as project } from './project.object';
// objectstack.config.ts
import * as objects from './src/objects';
import * as apps from './src/apps';
import * as views from './src/views';
import * as flows from './src/flows';
export default defineStack({
manifest: { id: 'com.example.pm', namespace: 'pm', version: '1.0.0', type: 'app', name: 'PM' },
objects: Object.values(objects),
apps: Object.values(apps),
views: Object.values(views),
flows: Object.values(flows),
});Strict Validation
defineStack() validates by default (strict: true):
1. Zod schemas — field names, types, enums 2. Cross-references — views/actions/workflows reference defined objects 3. Seed data — dataset objects exist in the definition
To disable (advanced — e.g., objects provided by another plugin):
export default defineStack({ ... }, { strict: false });Compile Artifact and Runtime Metadata Boundary
ObjectOS runtime metadata must come from source files during local development or from a compiled artifact. Do not configure a project runtime to read or write metadata through its business database.
objectstack compile
# -> dist/objectstack.json
OS_ARTIFACT_PATH=./dist/objectstack.json objectstack devRuntime rule of thumb:
| Context | Metadata source | Database role |
|---|---|---|
| Local dev | TS files or dist/objectstack.json | Business rows only |
| Production ObjectOS | Artifact API response | Business rows only |
| Control plane | Published JSON in metadata storage | Project revisions, history, overlays |
When generating objectstack.config.ts, keep object names short and snake_case; never set tableName, and do not add sys_metadata objects to a project runtime manifest.
---
Manifest Reference
Every stack needs a manifest to identify itself in the ecosystem:
manifest: {
id: 'com.example.crm', // Reverse domain unique ID
version: '1.0.0', // Semver
type: 'app', // app | plugin | driver | module | ...
name: 'Acme CRM', // Human-readable display name
description: 'CRM system', // Optional description
}Object naming: The object name is the canonical identifier and equals the physical table name. Embed any domain prefix directly in the name (e.g. name: 'crm_account'); the object-level namespace field is deprecated and ignored by the runtime.
`manifest.namespace` (ADR-0048): Optional, but enforced once set. When a package declares manifest.namespace: 'crm', every object.name must start with crm_ or defineStack errors (validateNamespacePrefix in @objectstack/spec); the legacy <ns>__<short> double-underscore form is rejected, and sys_-prefixed names are platform-reserved and exempt. The namespace is also a package-ownership key — installing two packages that both claim crm fails with NamespaceConflictError (downgrade to a warning with OS_METADATA_COLLISION=warn). os lint additionally emits a non-fatal naming/namespace-prefix warning for bare-named UI/automation items (app, page, dashboard, flow, action, report, dataset) when a namespace is set.
---
Driver Selection Guide
Drivers are the storage layer. Pick based on your environment:
| Driver | Package | Best For | Notes |
|---|---|---|---|
| Memory | @objectstack/driver-memory | Dev, testing, prototyping | Data lost on restart (unless persistence adapter used) |
| SQL | @objectstack/driver-sql | Production (PostgreSQL, MySQL, SQLite) | Uses Knex.js under the hood |
| Turso | @objectstack/driver-turso | Edge, serverless, multi-tenant | LibSQL/Turso cloud, per-tenant databases |
Usage Pattern
import { DriverPlugin } from '@objectstack/runtime';
// Development (in-memory, zero config)
import { InMemoryDriver } from '@objectstack/driver-memory';
new DriverPlugin(new InMemoryDriver())
// Production (SQLite)
import { SqlDriver } from '@objectstack/driver-sql';
new DriverPlugin(new SqlDriver({
client: 'better-sqlite3',
connection: { filename: './data/app.db' },
useNullAsDefault: true,
}))
// Production (PostgreSQL)
new DriverPlugin(new SqlDriver({
client: 'pg',
connection: process.env.DATABASE_URL,
}))
// Edge / Serverless (Turso)
import { TursoDriver } from '@objectstack/driver-turso';
new DriverPlugin(new TursoDriver({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
}))---
Adapter Selection Guide
Adapters bridge ObjectStack to web frameworks. All expose the same REST API.
| Adapter | Package | Use When |
|---|---|---|
| Hono | @objectstack/adapter-hono | Default choice. Lightweight, edge-ready, web-standard. |
| Express | @objectstack/adapter-express | Existing Express codebase. |
| Fastify | @objectstack/adapter-fastify | Need Fastify's schema validation / plugin ecosystem. |
| Next.js | @objectstack/adapter-nextjs | Full-stack React with App Router. |
| Nuxt | @objectstack/adapter-nuxt | Vue.js / Nuxt projects. |
| NestJS | @objectstack/adapter-nestjs | Enterprise Angular-style architecture. |
| SvelteKit | @objectstack/adapter-sveltekit | Svelte projects. |
Usage Pattern (Hono)
import { createHonoApp } from '@objectstack/adapter-hono';
const app = createHonoApp({
kernel, // ObjectKernel instance
prefix: '/api', // API route prefix (default: '/api')
});
export default app; // Deploy to Cloudflare Workers, Deno, Bun, NodeUsage Pattern (Next.js App Router)
// app/api/[...objectstack]/route.ts
import { createRouteHandler } from '@objectstack/adapter-nextjs';
import { kernel } from '@/lib/objectstack';
const handler = createRouteHandler({ kernel });
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const DELETE = handler;Pattern Across All Adapters
Every adapter follows the same architecture:
1. Accept a kernel (ObjectKernel) instance 2. Create an HttpDispatcher internally 3. Mount explicit routes for auth, GraphQL, storage, discovery 4. Delegate everything else to dispatcher.dispatch()
This means new routes added to HttpDispatcher work automatically in all adapters without code changes.
---
Runtime Boot Sequence
Understanding how ObjectStack starts helps debug and customize:
objectstack.config.ts
└── defineStack({ manifest, objects, views, ... })
│
▼
CLI: `os serve` / `os dev`
1. Load .env files (NODE_ENV-based)
2. Dynamic import of config file
3. Create Runtime + ObjectKernel
4. Auto-detect and register plugins:
├── ObjectQLPlugin (if objects defined)
├── DriverPlugin (memory in dev, SQL in prod)
├── AppPlugin (loads the defineStack bundle)
├── I18nServicePlugin (if translations/i18n defined)
├── AuthPlugin
├── Split platform-app plugins (ADR-0048, optional/best-effort, after AuthPlugin):
│ @objectstack/setup → createSetupAppPlugin (first-run wizard)
│ @objectstack/studio → createStudioAppPlugin
│ @objectstack/account → createAccountAppPlugin
├── HonoServerPlugin
├── RESTPlugin (auto-generated API)
├── DispatcherPlugin
└── AIServicePlugin (if available)
5. Runtime.start() → init + start all plugins
6. Server listens on the resolved port (see "Ports & networking" in Part 3)Port resolution (both os dev and os start → os serve): --port flag › $OS_PORT › $PORT › 3000. On a conflict the behaviour is mode-dependent — dev hops to the next free port, production fails loudly. See Ports & networking.
Plugin Loading Order Matters
Plugins initialize in registration order. Key dependencies:
| Plugin | Depends On | Reason |
|---|---|---|
| ObjectQLPlugin | (none) | Core data engine, should load first |
| DriverPlugin | (none) | Registers driver service |
| AppPlugin | ObjectQLPlugin | Registers objects/metadata with engine |
| AuthPlugin | ObjectQLPlugin | Needs user/session objects |
| RESTPlugin | ObjectQLPlugin, AppPlugin | Generates routes from registered objects |
| AIServicePlugin | ObjectQLPlugin, AppPlugin | Needs metadata for tool generation |
Programmatic Bootstrap (Without CLI)
import { Runtime, DriverPlugin, AppPlugin } from '@objectstack/runtime';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { InMemoryDriver } from '@objectstack/driver-memory';
import appConfig from './objectstack.config';
const runtime = new Runtime();
runtime.use(new ObjectQLPlugin());
runtime.use(new DriverPlugin(new InMemoryDriver()));
runtime.use(new AppPlugin(appConfig));
await runtime.start();
const kernel = runtime.getKernel();
// kernel is now ready — use it with an adapter---
Multi-App Composition
Use composeStacks() to merge multiple apps into one runtime:
import { composeStacks, defineStack } from '@objectstack/spec';
import CrmApp from './apps/crm/objectstack.config';
import TodoApp from './apps/todo/objectstack.config';
const combined = composeStacks([CrmApp, TodoApp], {
objectConflict: 'error', // Throw on duplicate object names
manifest: 'last', // Use last stack's manifest
});
export default combined;Conflict Strategies
| Strategy | Behavior |
|---|---|
'error' (default) | Throw if two stacks define the same object name |
'override' | Last stack wins — later definition replaces earlier |
'merge' | Shallow-merge objects with same name (later fields win) |
Host Pattern (Plugins as AppPlugin)
For a hosting environment where each app runs isolated:
import { Runtime, DriverPlugin, AppPlugin } from '@objectstack/runtime';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { AuthPlugin } from '@objectstack/plugin-auth';
export default defineStack({
manifest: { id: 'platform-host', type: 'app', version: '1.0.0', name: 'Platform' },
plugins: [
new ObjectQLPlugin(),
new DriverPlugin(new SqlDriver({ ... })),
new AuthPlugin({ secret: process.env.AUTH_SECRET }),
new AppPlugin(CrmApp), // contributes objects: crm_account, crm_lead, ...
new AppPlugin(TodoApp), // contributes objects: task, ...
],
});Each app registers its objects by their canonical name. Object names are globally unique and equal the physical table name — use them directly in queries, hooks, formulas, and REST URLs.
---
Seed Data
Declarative data loading for bootstrapping, demos, and testing:
export default defineStack({
// ... objects, apps, etc.
data: [
{
object: 'task',
mode: 'upsert', // 'upsert' | 'insert' | 'ignore' | 'replace'
externalId: 'subject', // Idempotency key for upsert matching
records: [
{ subject: 'Learn ObjectStack', status: 'open', priority: 'high' },
{ subject: 'Build first app', status: 'open', priority: 'medium' },
],
},
],
});| Mode | Behavior |
|---|---|
upsert (default) | Insert or update based on externalId match |
insert | Always insert (fails on duplicate) |
ignore | Insert if not exists, skip otherwise |
replace | Drop and re-insert all records |
---
CLI Commands
Daily commands are covered in Part 3 — Operations below (jump there). High-level cheat sheet for the bootstrap loop:
npx create-objectstack my-app --template full-stack
cd my-app && pnpm install
os dev --ui # dev server + Studio (auto-hops port if taken)
os validate # metadata cross-reference checks
os compile # produce dist/ artifact
os migrate plan # preview metadata↔DB schema drift (additive sync never alters existing columns)
os migrate apply # reconcile DB to metadata (loosening only; --allow-destructive for drops/tightenings)
PORT=8080 os start # production — pin the port explicitly (see Ports & networking)---
Complete Working Example
A minimal but complete project from scratch:
`package.json`:
{
"name": "my-todo-app",
"type": "module",
"dependencies": {
"@objectstack/spec": "^4.0.0",
"@objectstack/runtime": "^4.0.0",
"@objectstack/objectql": "^4.0.0",
"@objectstack/driver-memory": "^4.0.0",
"@objectstack/adapter-hono": "^4.0.0",
"@objectstack/cli": "^4.0.0"
}
}`src/objects/task.object.ts`:
import { Data } from '@objectstack/spec';
const { Field } = Data;
export default {
name: 'task',
label: 'Task',
fields: {
title: Field.text({ label: 'Title', required: true }),
description: Field.textarea({ label: 'Description' }),
status: Field.select({
label: 'Status',
options: [
{ label: 'Open', value: 'open' },
{ label: 'In Progress', value: 'in_progress' },
{ label: 'Done', value: 'done' },
],
defaultValue: 'open',
}),
priority: Field.select({
label: 'Priority',
options: [
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
],
defaultValue: 'medium',
}),
due_date: Field.date({ label: 'Due Date' }),
},
indexes: [
{ fields: ['status'] },
{ fields: ['due_date'] },
],
};`src/objects/index.ts`:
export { default as task } from './task.object';`objectstack.config.ts`:
import { defineStack } from '@objectstack/spec';
import * as objects from './src/objects';
export default defineStack({
manifest: {
id: 'com.example.todo',
version: '1.0.0',
type: 'app',
name: 'Todo Manager',
},
objects: Object.values(objects),
});# Run it
os dev --ui
# → Server at http://localhost:5174
# → REST API at http://localhost:5174/api
# → Studio UI at http://localhost:5174/studio--- ---
Part 2 — Plugin Development & Kernel Extension
When to Use This Skill
- You are creating a new plugin (driver, server, service, app feature)
- You need to register or consume services via the DI container
- You are using the hook/event system for inter-plugin communication
- You need to choose between ObjectKernel and LiteKernel
- You are debugging plugin loading order or dependency resolution
- You need to configure graceful shutdown, timeouts, or health checks
- You are implementing service factories with lifecycle management
---
Quick Reference — Detailed Rules
For comprehensive documentation with incorrect/correct examples:
- [Plugin Lifecycle](./rules/plugin-lifecycle.md) — 3-phase lifecycle (init/start/destroy), execution order, complete examples
- [Service Registry](./rules/service-registry.md) — DI container, factories, lifecycles (singleton/transient/scoped), core fallbacks
- [Hooks & Events](./rules/plugin-hooks-events.md) — Plugin hooks reference (→ objectstack-data)
---
ObjectKernel vs LiteKernel
| Feature | ObjectKernel | LiteKernel |
|---|---|---|
| Use case | Production servers, full applications | Serverless, edge, unit tests |
| Package | @objectstack/core | @objectstack/core |
| Plugin loading | Async with validation & metadata | Synchronous use() |
| Service factories | Singleton / Transient / Scoped | Direct instances only |
| Health monitoring | Built-in per-plugin health checks | Not available |
| Graceful shutdown | Timeout + rollback on failure | Basic destroy phase |
| Dependency resolution | Topological sort + circular detection | Topological sort |
| Core fallbacks | Auto-injects in-memory fallbacks | Not available |
| Config validation | Zod schema validation per plugin | Not available |
Decision Guide
What environment are you targeting?
│
├── Production server / full application?
│ └── ✅ ObjectKernel
│ • Full DI with factories and scopes
│ • Health monitoring and auto-recovery
│ • Graceful shutdown with timeout
│ • Startup failure rollback
│
├── Serverless / edge (Cloudflare Workers, Deno Deploy)?
│ └── ✅ LiteKernel
│ • Minimal memory footprint
│ • Fast cold start
│ • No background health checks
│
└── Unit tests (vitest)?
└── ✅ LiteKernel
• Simple setup, fast teardown
• No system requirement validation
• No shutdown signal handlersObjectKernel Configuration
import { ObjectKernel } from '@objectstack/core';
const kernel = new ObjectKernel({
logger: {
level: 'info', // 'debug' | 'info' | 'warn' | 'error' | 'fatal'
format: 'json', // 'json' | 'text' | 'pretty'
},
defaultStartupTimeout: 30000, // Per plugin (ms)
gracefulShutdown: true, // Register SIGINT/SIGTERM handlers
shutdownTimeout: 60000, // Total shutdown timeout (ms)
rollbackOnFailure: true, // Rollback all plugins if one fails
skipSystemValidation: false, // Skip system checks (useful for tests)
});LiteKernel Configuration
import { LiteKernel } from '@objectstack/core';
const kernel = new LiteKernel({
logger: { level: 'warn' },
});---
Plugin Interface — Quick Overview
import type { Plugin, PluginContext } from '@objectstack/core';
export interface Plugin {
name: string; // Unique identifier (reverse domain recommended)
version?: string; // Semantic version
type?: string; // 'standard' | 'ui' | 'driver' | 'server' | 'app'
dependencies?: string[]; // Plugins that must init before this one
// Phase 1: Register services
init(ctx: PluginContext): Promise<void> | void;
// Phase 2: Execute business logic (optional)
start?(ctx: PluginContext): Promise<void> | void;
// Phase 3: Cleanup (optional)
destroy?(): Promise<void> | void;
}See rules/plugin-lifecycle.md for complete examples.
---
PluginContext API
Service Registry
// Register a service (in init phase)
ctx.registerService('my-service', myServiceInstance);
// Get a service (in start phase)
const db = ctx.getService<IDataEngine>('objectql');
// Replace a service
ctx.replaceService('cache', new InstrumentedCache(existingCache));
// Get all services
const allServices: Map<string, any> = ctx.getServices();See rules/service-registry.md for factories and lifecycles.
Hook / Event System
// Register a hook handler
ctx.hook('kernel:ready', async () => {
ctx.logger.info('System is ready!');
});
// Register data lifecycle hooks
ctx.hook('data:beforeInsert', async (objectName, record) => {
if (objectName === 'task') {
record.created_at = new Date().toISOString();
}
});
// Trigger a custom hook
await ctx.trigger('my-plugin:initialized', { version: '1.0.0' });See rules/hooks-events.md for all 14 built-in hooks and patterns.
Logger
ctx.logger.debug('Detailed trace info', { key: 'value' });
ctx.logger.info('Plugin initialized');
ctx.logger.warn('Cache miss rate high', { rate: 0.45 });
ctx.logger.error('Connection failed', error);Kernel Access
const kernel = ctx.getKernel();
const isRunning = kernel.isRunning();
const state = kernel.getState(); // 'idle' | 'initializing' | 'running' | 'stopping' | 'stopped'---
Complete Plugin Example
// packages/plugins/plugin-audit/src/plugin.ts
import type { Plugin, PluginContext } from '@objectstack/core';
interface AuditEntry {
timestamp: string;
operation: string;
object: string;
recordId?: string;
}
class AuditService {
private log: AuditEntry[] = [];
record(entry: AuditEntry) {
this.log.push(entry);
}
getLog(): AuditEntry[] {
return [...this.log];
}
}
const AuditPlugin: Plugin = {
name: 'com.example.audit',
version: '1.0.0',
type: 'plugin',
dependencies: ['com.objectstack.engine.objectql'],
async init(ctx: PluginContext) {
// Phase 1: Register service and hooks
const auditService = new AuditService();
ctx.registerService('audit', auditService);
ctx.hook('data:afterInsert', async (objectName, _record, result) => {
auditService.record({
timestamp: new Date().toISOString(),
operation: 'insert',
object: objectName,
recordId: result?.id,
});
});
ctx.logger.info('Audit plugin initialized');
},
async start(ctx: PluginContext) {
// Phase 2: Log that audit is active
ctx.logger.info('Audit logging active');
},
async destroy() {
// Phase 3: Cleanup
},
};
export default AuditPlugin;---
Using Plugins
import { ObjectKernel } from '@objectstack/core';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { DriverPlugin } from '@objectstack/runtime';
import { InMemoryDriver } from '@objectstack/driver-memory';
import AuditPlugin from './plugin';
const kernel = new ObjectKernel();
await kernel.use(new ObjectQLPlugin());
await kernel.use(new DriverPlugin(new InMemoryDriver()));
await kernel.use(AuditPlugin);
await kernel.bootstrap();
// Services are now available
const audit = kernel.getService<AuditService>('audit');---
Testing Plugins
import { describe, it, expect } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import AuditPlugin from './plugin';
describe('AuditPlugin', () => {
it('records insert events', async () => {
const kernel = new LiteKernel({ logger: { level: 'silent' } });
kernel.use(AuditPlugin);
await kernel.bootstrap();
// Simulate a data event
await kernel.context.trigger('data:afterInsert', 'task', {}, { id: '123' });
const audit = kernel.getService('audit');
const log = audit.getLog();
expect(log).toHaveLength(1);
expect(log[0].operation).toBe('insert');
expect(log[0].object).toBe('task');
await kernel.shutdown();
});
});---
Well-Known Plugin Names & Services
| Plugin Name | Service Key | Package |
|---|---|---|
com.objectstack.engine.objectql | objectql | @objectstack/objectql |
com.objectstack.driver.* | driver.{name} | @objectstack/driver-* |
com.objectstack.auth | auth | @objectstack/plugin-auth |
com.objectstack.rest | rest | @objectstack/rest |
com.objectstack.metadata | metadata | @objectstack/metadata |
com.objectstack.realtime | realtime | @objectstack/service-realtime |
com.objectstack.cache | cache | @objectstack/service-cache |
com.objectstack.setup | — | @objectstack/setup → createSetupAppPlugin (ADR-0048 one-app pkg) |
com.objectstack.studio | — | @objectstack/studio → createStudioAppPlugin |
com.objectstack.account | — | @objectstack/account → createAccountAppPlugin |
com.objectstack.cloud-connection | — | @objectstack/cloud-connection → createCloudConnectionPlugin |
---
MetadataPlugin Runtime Boundary
MetadataPlugin is the IMetadataService provider for ObjectOS, but runtime metadata is read-only and artifact/file backed:
- Do not register
sys_metadataorsys_metadata_historyfrom an ObjectOS
runtime plugin. Those persistence tables belong to the control plane. (Exception, #1826: an isolated project kernel may opt into sys_metadata hydration from its own DB — the general boundary otherwise stands.)
- Do not call
MetadataManager.setDataEngine()automatically from
MetadataPlugin.start(). Project databases must contain business rows only.
- Use
artifactSource: { mode: 'local-file', path: './dist/objectstack.json' }
for local artifact boot; production should use the Artifact API loader once wired.
DatabaseLoader,setDatabaseDriver(), andsetDataEngine()remain valid for
control-plane services that explicitly own metadata revisions, history, or overlays.
import { MetadataPlugin } from '@objectstack/metadata';
await kernel.use(new MetadataPlugin({
watch: false,
artifactSource: { mode: 'local-file', path: './dist/objectstack.json' },
}));---
Health Monitoring (ObjectKernel Only)
const MyPlugin: Plugin & { healthCheck(): Promise<PluginHealthStatus> } = {
name: 'com.example.db',
version: '1.0.0',
async init(ctx) { /* ... */ },
async healthCheck() {
try {
await this.pool.query('SELECT 1');
return { healthy: true, message: 'Database connected' };
} catch (err) {
return { healthy: false, message: 'Database unreachable', details: { error: err.message } };
}
},
};
// Check health
const health = await kernel.checkPluginHealth('com.example.db');
const allHealth = await kernel.checkAllPluginsHealth();
// Get startup metrics
const metrics = kernel.getPluginMetrics();
// Map<string, number> — plugin name → startup duration in ms---
Feature Flags
import { defineStack } from '@objectstack/spec';
export default defineStack({
featureFlags: [
{
name: 'experimental_ai_copilot',
label: 'AI Copilot',
enabled: true,
strategy: 'percentage',
conditions: { percentage: 25 }, // 25% of users
environment: ['production'],
},
{
name: 'beta_kanban_view',
label: 'Kanban View',
enabled: true,
strategy: 'group',
conditions: { groups: ['beta_testers'] },
},
],
});Strategies: boolean | percentage | user_list | group | custom
--- ---
Part 3 — Operations: CLI, Testing, Deployment
The @objectstack/cli package ships an os binary (alias: objectstack). Every project gets the same command surface — pnpm install does not need to be re-run when commands are added.
Daily-loop commands
| Command | What it does |
|---|---|
os init | Scaffold a new project (alternative to npx create-objectstack) |
os dev | Start the dev server with hot metadata reload. --fresh = ephemeral clean DB + auto --seed-admin, which POSTs a sign-up after boot (default admin@objectos.ai / admin123; override with --admin-email / --admin-password). The seeded human is auto-promoted to platform admin, so Setup/Studio work on first login. |
os studio | Launch Studio UI against the local stack |
os validate | Validate objectstack.config.ts — Zod protocol schema, CEL/predicate validation (record.<field> existence), and widget-binding integrity. Same gates as os build, no artifact emitted. See Verify your work. |
os lint | Style/convention lint on metadata files |
os info | Print resolved stack info (env, drivers, adapter, plugin list) |
os doctor | Diagnose common setup issues |
Build & runtime
| Command | What it does |
|---|---|
os build | Compile TS metadata, bundle, and produce dist/ |
os compile | Compile to portable JSON artifact (for runtime hydration) |
os serve | Serve a compiled stack in production mode |
os start | Production-grade boot (validates env, applies migrations, starts adapter) |
os generate <kind> | Scaffold an object / view / flow / agent from a template |
Verify your work
ObjectStack metadata mistakes fail silently at runtime, not at edit time: a bare field ref in a predicate (done instead of record.done) evaluates to null and silently hides an action/validation on every record (#2183/#2185); a dangling dashboard widget binding renders an empty chart (ADR-0021). Both are caught at author time by one command:
os validate # Zod schema + CEL predicates + widget bindings — no artifact
# or
os build # the same three gates, plus emits dist/objectstack.jsonos validate and os build run the same structural + semantic gates:
1. Zod protocol schema — the stack conforms to @objectstack/spec. 2. CEL / predicate validation (ADR-0032) — every visible / disabled / requiredWhen / validation rule / flow condition / sharing rule is parsed for CEL syntax and checked that each record.<field> reference exists on the target object. A bare field (missing record.) fails here. 3. Widget-binding integrity (ADR-0021) — every dashboard widget's dataset / dimensions / values resolves to a declared dataset/field.
Both exit non-zero with a located, corrective message; os build additionally emits the artifact. Use os validate as the fast inner-loop check after editing metadata and os build when you need dist/. In a scaffolded project these are npm run validate / npm run build.
Rule of thumb: never report a metadata change as done until `os validate` passes. (os lint is a separate style/convention pass — naming, labels, namespace prefixes — and does not replace os validate.)
Ports & networking
Port resolution is the same for os dev and os start (both spawn os serve):
--port <n> › $OS_PORT › $PORT › 3000 (default)Conflict behaviour is mode-dependent — this is deliberate:
| Mode | If the resolved port is busy |
|---|---|
Dev (os dev, or NODE_ENV=development) | Auto-hops to the next free port (up to +100) so several example apps run side-by-side. The startup banner shows the actual bound port. |
Production (os start) | Fails loudly and exits 1. It never silently drifts — a shifted port breaks reverse-proxy upstreams, better-auth callback URLs, and CORS trusted-origins as opaque 403/502s. |
Production guidance:
- Pin the port explicitly —
PORT=8080 os start(or--port 8080). Don't
rely on the 3000 default; it collides easily on shared hosts.
- Keep these in sync when you change the port (mismatch ⇒ better-auth
Invalid origin 403 / CORS failures):
- reverse-proxy upstream (
nginx/caddy) OS_AUTH_URL/ better-authbaseURL+callbackURLOS_TRUSTED_ORIGINS(CORS allow-list)- the app's
hostname - Recommended topology: terminate TLS on a reverse proxy (
:443) and let
the app listen on an internal high port (e.g. 8080) fixed via PORT.
Data & migrations
| Command | What it does |
|---|---|
os data seed | Run all defineDataset() entries scoped to current env |
os data export / import | Bulk import / export records as JSONL |
os diff | Show schema diff between local and target environment |
os meta apply | Apply metadata + data migrations to the target |
os rollback | Roll back the most recent migration batch |
Environments & deploy
| Command | What it does |
|---|---|
os login / logout / whoami | Auth against the ObjectStack cloud control plane |
os environments list / create / switch | Manage cloud environments (prod/staging/dev) |
os publish | Push the compiled stack to a cloud environment |
os register | Register the local stack as a deployable target |
os cloud … | Cloud-specific subcommands (logs, metrics, status) |
os package publish [dist/objectstack.json] [--env … --install --visibility org] | Upload the compiled artifact as a versioned package to the cloud catalog (ADR-0008 P3) |
os package install <manifest-id │ ./dist/objectstack.json> [--version │ --runtime http://localhost:3000] | Install a package into a running runtime via its install-local endpoint. Catalog mode (by manifest id) or air-gapped local-artifact mode. Auths with the target runtime's session (--email/--password or OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD), not the cloud login |
Cloud connection & marketplace (`@objectstack/cloud-connection`, ADR-0008/0009).
The open runtime-side cloud client. Its plugins —
CloudConnectionPlugin/createCloudConnectionPlugin,MarketplaceProxyPlugin,
MarketplaceInstallLocalPlugin,RuntimeConfigPlugin— expose the install-local
endpoint that os package install targets, ship the Installed Apps page andmarketplace Setup nav as plugin metadata, and maintain LocalManifestSource(a local desired-state ledger) plus runtime-identity bind v2 (environment-less
self-hosted binding).
Testing pattern
Use LiteKernel for unit / integration tests — it skips the cloud bits and plugin discovery, so tests run in milliseconds:
import { describe, it, expect } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import stack from '../objectstack.config';
describe('account hooks', () => {
it('defaults industry to "Other"', async () => {
const kernel = await LiteKernel.create({ stack });
const created = await kernel.api('account').create({ name: 'Acme' });
expect(created.industry).toBe('Other');
await kernel.shutdown();
});
});- Seed in tests: call
kernel.seed(SeedData)after create. See
objectstack-data for env-scoped fixtures (env: ['test']).
- Reset between tests: prefer
await kernel.reset()over recreating —
it's an order of magnitude faster.
- HTTP-level tests: mount the adapter (Hono / Express) on a random
port and use fetch. The adapter is just middleware.
Deployment targets
| Target | Driver | Adapter | Notes |
|---|---|---|---|
| Node.js server | driver-postgres / driver-sqlite | adapter-hono / adapter-express | Default — works anywhere Node runs |
| Edge (Cloudflare Workers, Vercel Edge) | driver-turso / driver-d1 | adapter-hono | Cold-start friendly; LiteKernel only |
| Serverless (Lambda, Vercel functions) | driver-postgres (with pooler) | adapter-nextjs / adapter-express | Mind cold-start: prefer LiteKernel |
| Browser / WebContainer | driver-sqlite-wasm | none (in-process) | Studio playground, demos |
| Docker / Kubernetes | any | any | Use os start as the entrypoint; pin PORT and EXPOSE it (see Ports & networking) |
Health & observability
- Health endpoint: the adapter auto-exposes
GET /healthzand
GET /readyz when the kernel reports ready (see "Health Monitoring" earlier in this skill).
- Logs: plugins log via
ctx.logger. Configure the sink in
defineStack({ logging: { sink: 'pino' | 'console' | custom } }).
- Metrics: the kernel exposes a
metricsservice; install
@objectstack/plugin-prometheus for an OpenMetrics scrape endpoint.
Common ops pitfalls
| Symptom | Likely cause |
|---|---|
os dev hangs at "Loading metadata…" | Circular import in objectstack.config.ts — run os validate |
os start exits with "Port N is already in use" | Intended: production never auto-shifts ports. Free the port or set PORT=<n> — see Ports & networking |
better-auth Invalid origin 403 after a port/host change | Port or hostname out of sync with OS_AUTH_URL / OS_TRUSTED_ORIGINS — see Ports & networking |
| Migrations apply locally but not in cloud | env scoping on the dataset excludes the target environment |
| Adapter 404s on auto-generated routes | enable.apiEnabled: false on the object, or missing os build |
| LiteKernel test passes, ObjectKernel boot fails | Test missed a plugin dependency — list with os info |
| Hot reload misses new objects | Barrel src/objects/index.ts not re-exporting — check the file |
| Login works but Setup / Studio missing | The logged-in user isn't a platform admin. Setup/Studio are gated by setup.access / studio.access on admin_full_access, auto-granted only to the first registered human (bootstrapPlatformAdmin). The usr_system seed identity is skipped, so it can't steal the grant. Either sign up first (--seed-admin/--fresh does this) or check sys_user_permission_set for a cross-tenant (organization_id = NULL) admin_full_access link on your user. Don't edit nav code first. |
---
References
See references/_index.md for the full list of Zod schemas (with one-line descriptions) — pointers into node_modules/@objectstack/spec/src/. Always Read the source for exact field shapes; do not rely on memory of property names.
Evaluation Tests (evals/)
This directory is reserved for future skill evaluation tests.
Purpose
Evaluation tests (evals) validate that AI assistants correctly understand and apply the rules defined in this skill when generating code or providing guidance.
Structure
When implemented, evals will follow this structure:
evals/
├── naming/
│ ├── test-object-names.md
│ ├── test-field-keys.md
│ └── test-option-values.md
├── relationships/
│ ├── test-lookup-vs-master-detail.md
│ └── test-junction-patterns.md
├── validation/
│ ├── test-script-inversion.md
│ └── test-state-machine.md
└── ...Format
Each eval file will contain: 1. Scenario — Description of the task 2. Expected Output — Correct implementation 3. Common Mistakes — Incorrect patterns to avoid 4. Validation Criteria — How to score the output
Status
⚠️ Not yet implemented — This is a placeholder for future development.
Contributing
When adding evals: 1. Each eval should test a single, specific rule or pattern 2. Include both positive (correct) and negative (incorrect) examples 3. Reference the corresponding rule file in rules/ 4. Use realistic scenarios from actual ObjectStack projects
objectstack-platform — Schema References
Auto-generated by packages/spec/scripts/build-skill-references.ts.Do not edit — re-run pnpm --filter @objectstack/spec run gen:skill-refs to update.Schemas live in the published @objectstack/spec package. Read them directly from node_modules — there is no local copy in the skill bundle.
Core schemas
node_modules/@objectstack/spec/src/data/dataset.zod.ts— Data Import Strategynode_modules/@objectstack/spec/src/data/datasource.zod.ts— Driver Identifiernode_modules/@objectstack/spec/src/kernel/context.zod.ts— Runtime Mode Enumnode_modules/@objectstack/spec/src/kernel/feature.zod.ts— Feature Rollout Strategynode_modules/@objectstack/spec/src/kernel/manifest.zod.ts— Schema for the ObjectStack Manifest.node_modules/@objectstack/spec/src/kernel/metadata-plugin.zod.ts— Metadata Plugin Protocolnode_modules/@objectstack/spec/src/kernel/plugin-capability.zod.ts— Plugin Capability Protocolnode_modules/@objectstack/spec/src/kernel/plugin-lifecycle-events.zod.ts— Plugin Lifecycle Events Protocolnode_modules/@objectstack/spec/src/kernel/plugin-loading.zod.ts— Plugin Loading Protocolnode_modules/@objectstack/spec/src/kernel/plugin.zod.ts— Upgrade Context Schemanode_modules/@objectstack/spec/src/kernel/service-registry.zod.ts— Service Registry Protocol
Transitive dependencies
node_modules/@objectstack/spec/src/kernel/cluster.zod.ts— Cluster Protocolnode_modules/@objectstack/spec/src/kernel/metadata-customization.zod.ts— Metadata Customization Layer Protocolnode_modules/@objectstack/spec/src/kernel/metadata-loader.zod.ts— Metadata Loader Protocolnode_modules/@objectstack/spec/src/shared/expression.zod.ts— Expression Protocolnode_modules/@objectstack/spec/src/shared/identifiers.zod.ts— System Identifier Schemanode_modules/@objectstack/spec/src/shared/lazy-schema.ts— Wrap a Zod schema constructor so its body is only evaluated on first use.
How to read these
1. The schemas are runtime Zod definitions. Use Read on the absolute path under node_modules/@objectstack/spec/src/ to inspect field shapes, .describe() text, enums, and refinements. 2. TypeScript types: import type { … } from '@objectstack/spec' (or the matching subpath export). 3. Runtime values: import { … } from '@objectstack/spec' — the package re-exports every schema and helper.
Hook & Event System
Complete guide for using hooks and events in ObjectStack plugins.
Hook Registration
Register hook handlers in init() or start():
async init(ctx: PluginContext) {
// Register a hook handler
ctx.hook('kernel:ready', async () => {
ctx.logger.info('System is ready!');
});
// Register data lifecycle hooks
ctx.hook('data:beforeInsert', async (objectName, record) => {
if (objectName === 'task') {
record.created_at = new Date().toISOString();
}
});
}Triggering Events
Trigger custom hooks to notify other plugins:
async start(ctx: PluginContext) {
// Trigger a custom event
await ctx.trigger('my-plugin:initialized', { version: '1.0.0' });
}Built-in Hooks
Kernel Lifecycle Hooks
| Hook | Triggered When | Arguments |
|---|---|---|
kernel:ready | All plugins started, system validated | (none) |
kernel:shutdown | Shutdown begins | (none) |
Data Lifecycle Hooks
| Hook | Triggered When | Arguments |
|---|---|---|
data:beforeInsert | Before a record is created | (objectName, record) |
data:afterInsert | After a record is created | (objectName, record, result) |
data:beforeUpdate | Before a record is updated | (objectName, id, record) |
data:afterUpdate | After a record is updated | (objectName, id, record, result) |
data:beforeDelete | Before a record is deleted | (objectName, id) |
data:afterDelete | After a record is deleted | (objectName, id, result) |
data:beforeFind | Before querying records | (objectName, query) |
data:afterFind | After querying records | (objectName, query, result) |
Metadata Hooks
| Hook | Triggered When | Arguments |
|---|---|---|
metadata:changed | Metadata is registered or updated | (type, name, metadata) |
Custom Hooks
Create your own hooks following the convention: {plugin-namespace}:{event-name}.
// In your plugin
async start(ctx: PluginContext) {
await ctx.trigger('analytics:pageview', {
path: '/dashboard',
userId: '123',
});
}
// In another plugin
async init(ctx: PluginContext) {
ctx.hook('analytics:pageview', async (data) => {
console.log('Page viewed:', data.path);
});
}Hook Handler Patterns
Simple Handler
ctx.hook('kernel:ready', async () => {
console.log('System ready');
});Handler with Data
ctx.hook('data:afterInsert', async (objectName, record, result) => {
console.log(`Created ${objectName} record:`, result.id);
});Handler with Context
ctx.hook('data:beforeInsert', async (objectName, record) => {
// Access kernel context
const user = ctx.getService('auth').getCurrentUser();
record.created_by = user.id;
});Async Error Handling
ctx.hook('data:afterInsert', async (objectName, record, result) => {
try {
await sendNotification(record);
} catch (error) {
ctx.logger.error('Failed to send notification', error);
// Don't throw — let other hooks continue
}
});Incorrect vs Correct
❌ Incorrect — Blocking Hook with Slow Operation
ctx.hook('data:beforeInsert', async (objectName, record) => {
// ❌ Blocks transaction
await sendEmail(record.email);
await callExternalAPI(record);
});✅ Correct — Use after* Hook for Side Effects
ctx.hook('data:afterInsert', async (objectName, record, result) {
// ✅ Non-blocking, outside transaction
try {
await sendEmail(record.email);
await callExternalAPI(record);
} catch (error) {
ctx.logger.error('Side effect failed', error);
}
});❌ Incorrect — Throwing in after* Hook
ctx.hook('data:afterInsert', async (objectName, record, result) {
throw new Error('Notification failed'); // ❌ Too late to abort
});✅ Correct — Logging Errors in after* Hook
ctx.hook('data:afterInsert', async (objectName, record, result) {
try {
await sendNotification(result);
} catch (error) {
ctx.logger.error('Notification failed', error); // ✅ Log, don't throw
}
});❌ Incorrect — Modifying result in before* Hook
ctx.hook('data:beforeInsert', async (objectName, record) => {
record.result = { id: '123' }; // ❌ result doesn't exist yet
});✅ Correct — Modifying input in before* Hook
ctx.hook('data:beforeInsert', async (objectName, record) {
record.created_at = new Date().toISOString(); // ✅ Modify input
});Common Patterns
Setting Defaults
ctx.hook('data:beforeInsert', async (objectName, record) => {
if (objectName === 'task') {
record.status = record.status || 'pending';
record.priority = record.priority || 'medium';
}
});Audit Logging
ctx.hook('data:afterInsert', async (objectName, record, result) => {
const audit = ctx.getService('audit');
await audit.log({
action: 'create',
object: objectName,
recordId: result.id,
timestamp: new Date().toISOString(),
});
});Triggering Workflows
ctx.hook('data:afterUpdate', async (objectName, id, record, result) => {
if (objectName === 'opportunity' && record.stage === 'won') {
await ctx.trigger('sales:opportunity-won', { id, record: result });
}
});Cross-Object Updates
ctx.hook('data:afterInsert', async (objectName, record, result) => {
if (objectName === 'invoice_line_item') {
// Update invoice total
const engine = ctx.getService('objectql');
await engine.object('invoice').update(record.invoice_id, {
updated_at: new Date().toISOString(),
});
}
});Validation
ctx.hook('data:beforeInsert', async (objectName, record) => {
if (objectName === 'account') {
if (!record.email || !record.email.includes('@')) {
throw new Error('Valid email is required');
}
}
});Hook Execution Order
Hooks are executed in registration order within each plugin, then by plugin initialization order.
// Plugin A (depends on nothing)
ctx.hook('kernel:ready', () => console.log('A'));
// Plugin B (depends on A)
ctx.hook('kernel:ready', () => console.log('B'));
// Output: A, BPerformance Considerations
before* Hooks
- ⚠️ Block the operation — keep fast
- ⚠️ Run inside transaction — don't call slow APIs
- ✅ Use for validation and data enrichment
- ✅ Throw errors to abort operation
after* Hooks
- ⚠️ Still block by default — use sparingly
- ✅ Use for notifications and logging
- ✅ Use try/catch to prevent cascading failures
- ✅ Consider async execution (if supported)
Hook Naming Conventions
Follow the pattern: {namespace}:{event-name}
Good names:
auth:user-loginsales:opportunity-createdbilling:invoice-paidanalytics:event-tracked
Bad names:
userLogin(no namespace)auth.user.login(use colons, not dots)auth:USER_LOGIN(use lowercase)
Testing Hooks
import { describe, it, expect } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import MyPlugin from './plugin';
describe('Hook System', () => {
it('executes hook handler', async () => {
const kernel = new LiteKernel();
let hookCalled = false;
kernel.use({
name: 'test-plugin',
async init(ctx) {
ctx.hook('test:event', async () => {
hookCalled = true;
});
},
});
await kernel.bootstrap();
await kernel.context.trigger('test:event');
expect(hookCalled).toBe(true);
await kernel.shutdown();
});
it('passes arguments to hook handler', async () => {
const kernel = new LiteKernel();
let receivedData: any;
kernel.use({
name: 'test-plugin',
async init(ctx) {
ctx.hook('test:event', async (data) => {
receivedData = data;
});
},
});
await kernel.bootstrap();
await kernel.context.trigger('test:event', { foo: 'bar' });
expect(receivedData).toEqual({ foo: 'bar' });
await kernel.shutdown();
});
});Best Practices
1. *Use before for validation — Abort operations early 2. Use after for side effects — Notifications, logging, external API calls 3. Keep hooks fast — Especially before hooks 4. *Use try/catch in after hooks — Don't let one failure cascade 5. Use descriptive hook names — Follow `{namespace}:{event-name}` convention 6. Document custom hooks — What they do, what arguments they pass 7. Don't mutate arguments* — Except for `record` in before hooks 8. Test hook handlers — Verify they execute and handle errors 9. Limit hook count — Too many hooks slow down operations 10. Use specific object names — Don't hook all objects unless necessary
Project Bootstrap Patterns
Guide for bootstrapping ObjectStack projects with defineStack().
Basic Stack Configuration
import { defineStack } from '@objectstack/spec';
import { DriverPlugin } from '@objectstack/runtime';
import { TursoDriver } from '@objectstack/driver-turso';
export default defineStack({
manifest: {
name: 'my-crm',
version: '1.0.0',
description: 'Customer relationship management system',
},
driver: new DriverPlugin(
new TursoDriver({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_AUTH_TOKEN!,
})
),
objects: [
/* ... */
],
});Driver Selection
| Driver | Use Case |
|---|---|
InMemoryDriver | Development, testing |
SQLiteDriver | Local development, small deployments |
TursoDriver | Production (edge database) |
PostgreSQLDriver | Production (full-featured) |
Adapter Selection
| Adapter | Framework |
|---|---|
@objectstack/adapter-express | Express.js |
@objectstack/adapter-fastify | Fastify |
@objectstack/adapter-hono | Hono |
@objectstack/adapter-nextjs | Next.js |
Incorrect vs Correct
❌ Incorrect — Missing Driver
export default defineStack({
manifest: { /* ... */ },
// ❌ No driver specified
objects: [/* ... */],
});✅ Correct — Driver Configured
export default defineStack({
manifest: { /* ... */ },
driver: new DriverPlugin(new InMemoryDriver()), // ✅ Driver specified
objects: [/* ... */],
});Best Practices
1. Choose appropriate driver — Match to deployment environment 2. Use environment variables — Don't hardcode credentials 3. Configure logging — Set appropriate log level 4. Enable features — trackHistory, feeds, activities as needed 5. Organize objects — Group by domain/module
---
See parent skill for complete documentation: ../SKILL.md
Plugin Hooks & Events (Reference)
Note: This document is a reference pointer. Complete documentation has been moved to the canonical hooks skill.
---
Complete Documentation
For comprehensive plugin hooks and event system documentation, see:
→ [objectstack-platform/references/plugin-hooks.md](../../objectstack-platform/references/plugin-hooks.md)
The canonical reference includes:
- Complete hook registration API (
ctx.hook,ctx.trigger) - All built-in hooks (kernel lifecycle + data events)
- Custom plugin event patterns
- Hook handler patterns and error handling
- Performance considerations
- Testing strategies
- Best practices
---
Quick Reference
Hook Registration
Register hook handlers in init() or start():
async init(ctx: PluginContext) {
// Kernel lifecycle hook
ctx.hook('kernel:ready', async () => {
ctx.logger.info('System ready');
});
// Data lifecycle hook
ctx.hook('data:beforeInsert', async (objectName, record) => {
if (objectName === 'task') {
record.created_at = new Date().toISOString();
}
});
}Triggering Custom Events
async start(ctx: PluginContext) {
await ctx.trigger('my-plugin:initialized', { version: '1.0.0' });
}Built-in Hooks
Kernel Lifecycle:
kernel:ready— All plugins started, system validatedkernel:shutdown— Shutdown begins
Data Lifecycle:
data:beforeInsert— Before record createddata:afterInsert— After record createddata:beforeUpdate— Before record updateddata:afterUpdate— After record updateddata:beforeDelete— Before record deleteddata:afterDelete— After record deleteddata:beforeFind— Before querying recordsdata:afterFind— After querying records
Metadata:
metadata:changed— Metadata registered or updated
Custom Hooks
Follow the convention: {plugin-namespace}:{event-name}
// Trigger
await ctx.trigger('analytics:pageview', { path: '/dashboard', userId: '123' });
// Subscribe
ctx.hook('analytics:pageview', async (data) => {
console.log('Page viewed:', data.path);
});---
Common Patterns
Setting Defaults
ctx.hook('data:beforeInsert', async (objectName, record) => {
if (objectName === 'task') {
record.status = record.status || 'pending';
}
});Audit Logging
ctx.hook('data:afterInsert', async (objectName, record, result) => {
const audit = ctx.getService('audit');
await audit.log({
action: 'create',
object: objectName,
recordId: result.id,
});
});Triggering Workflows
ctx.hook('data:afterUpdate', async (objectName, id, record, result) => {
if (objectName === 'opportunity' && record.stage === 'won') {
await ctx.trigger('sales:opportunity-won', { id, record: result });
}
});---
Best Practices
✅ DO: 1. Use before* for validation 2. Use after* for side effects (notifications, logging, external API calls) 3. Keep hooks fast — especially before* hooks 4. Use try/catch in after* hooks — don't let one failure cascade 5. Follow naming convention: {namespace}:{event-name} 6. Test hook handlers thoroughly
❌ DON'T: 1. Don't block operations with slow external API calls in before* hooks 2. Don't throw in after* hooks (use try/catch and log errors) 3. Don't mutate arguments (except record in before* hooks) 4. Don't create circular dependencies between plugins 5. Don't hook all objects unless necessary
---
Hook Execution Order
Hooks execute in registration order within each plugin, then by plugin initialization order (based on dependencies).
---
See Also
- [objectstack-data/SKILL.md#lifecycle-hooks](../../objectstack-data/SKILL.md#lifecycle-hooks) — Complete hooks system overview
- [objectstack-platform/references/plugin-hooks.md](../../objectstack-platform/references/plugin-hooks.md) — Full plugin hooks documentation
- [objectstack-data/references/data-hooks.md](../../objectstack-data/references/data-hooks.md) — Data lifecycle hooks
- [Plugin Lifecycle](./plugin-lifecycle.md) — 3-phase plugin lifecycle
- [Service Registry](./service-registry.md) — DI container and service management
---
For complete documentation with detailed examples, hook context API, testing strategies, and performance optimization, see the canonical reference:
→ [objectstack-platform/references/plugin-hooks.md](../../objectstack-platform/references/plugin-hooks.md)
Plugin Lifecycle
Complete guide for implementing plugin lifecycle phases in ObjectStack.
Three-Phase Lifecycle
kernel.bootstrap()
│
├── Phase 1: INIT (register services)
│ ├── PluginA.init(ctx) → ctx.registerService('db', dbInstance)
│ ├── PluginB.init(ctx) → ctx.registerService('cache', cacheInstance)
│ └── PluginC.init(ctx) → ctx.registerService('http', httpServer)
│ │
│ └── [Core fallback injection — auto-fills missing 'core' services]
│
├── Phase 2: START (business logic)
│ ├── PluginA.start(ctx) → connect to database
│ ├── PluginB.start(ctx) → warm cache
│ └── PluginC.start(ctx) → bind routes, listen on port
│
└── Phase 3: READY
└── ctx.trigger('kernel:ready')
└── All hook handlers execute
kernel.shutdown()
│
├── ctx.trigger('kernel:shutdown')
├── PluginC.destroy() → close server
├── PluginB.destroy() → flush cache
└── PluginA.destroy() → disconnect DBPlugin Interface
import type { Plugin, PluginContext } from '@objectstack/core';
export interface Plugin {
/** Unique name (reverse domain recommended) */
name: string;
/** Semantic version */
version?: string;
/** Plugin type */
type?: string; // 'standard' | 'ui' | 'driver' | 'server' | 'app' | 'theme' | 'agent'
/** Plugins that must init before this one */
dependencies?: string[];
/** Phase 1: Register services — called during kernel init */
init(ctx: PluginContext): Promise<void> | void;
/** Phase 2: Execute business logic — called after ALL plugins init */
start?(ctx: PluginContext): Promise<void> | void;
/** Phase 3: Cleanup — called during kernel shutdown */
destroy?(): Promise<void> | void;
}Key Rules
1. `init()` is required — This is where you register services 2. `start()` is optional — Only needed if your plugin has active behavior 3. `destroy()` is optional — Only needed if you hold resources to release 4. Plugins init in dependency order — Topological sort on dependencies 5. Plugins destroy in reverse order — LIFO cleanup 6. Each phase completes for ALL plugins before the next phase begins
Phase 1: init() — Service Registration
Purpose: Register services in the DI container.
When to use:
- Register database connections
- Register cache instances
- Register HTTP servers
- Register hook handlers
- Register factories
Do NOT:
- Connect to databases (do in
start()) - Listen on ports (do in
start()) - Make external API calls
Example
async init(ctx: PluginContext) {
// Register a service
const pool = createPool({ /* config */ });
ctx.registerService('db-pool', pool);
// Register hook handlers
ctx.hook('kernel:ready', async () => {
ctx.logger.info('System ready');
});
// Register data hooks
ctx.hook('data:beforeInsert', async (objectName, record) => {
if (objectName === 'task') {
record.created_at = new Date().toISOString();
}
});
ctx.logger.info('Plugin initialized');
}Phase 2: start() — Active Behavior
Purpose: Execute business logic that requires all services to be available.
When to use:
- Connect to databases
- Listen on HTTP ports
- Start background workers
- Warm caches
- Register routes
Safe to:
- Call
ctx.getService()— all services are registered - Trigger events via
ctx.trigger() - Make external API calls
Example
async start(ctx: PluginContext) {
// All services are now available
const pool = ctx.getService('db-pool');
await pool.connect();
const server = ctx.getService('http-server');
await server.listen(3000);
ctx.logger.info('Plugin started');
}Phase 3: destroy() — Cleanup
Purpose: Release resources held by the plugin.
When to use:
- Close database connections
- Stop HTTP servers
- Flush caches
- Cancel background workers
- Release file handles
Runs in reverse order — Last plugin to start is first to destroy.
Example
async destroy() {
if (this.pool) {
await this.pool.close();
}
if (this.server) {
await this.server.close();
}
console.log('Plugin destroyed');
}Incorrect vs Correct
❌ Incorrect — Connecting in init()
async init(ctx: PluginContext) {
const pool = createPool({ /* config */ });
await pool.connect(); // ❌ Don't connect in init()
ctx.registerService('db-pool', pool);
}✅ Correct — Connecting in start()
async init(ctx: PluginContext) {
const pool = createPool({ /* config */ });
ctx.registerService('db-pool', pool); // ✅ Just register
}
async start(ctx: PluginContext) {
const pool = ctx.getService('db-pool');
await pool.connect(); // ✅ Connect in start()
}❌ Incorrect — Using getService() in init()
async init(ctx: PluginContext) {
const db = ctx.getService('db-pool'); // ❌ May not exist yet
ctx.registerService('cache', new Cache(db));
}✅ Correct — Using getService() in start()
async init(ctx: PluginContext) {
ctx.registerService('cache', null); // ✅ Register placeholder
}
async start(ctx: PluginContext) {
const db = ctx.getService('db-pool'); // ✅ Safe — all services registered
const cache = new Cache(db);
ctx.replaceService('cache', cache);
}❌ Incorrect — Missing destroy()
// Plugin opens file handles, database connections, but no destroy()
async start(ctx: PluginContext) {
this.db = await connectDatabase();
this.fileHandle = fs.openSync('/tmp/data.log');
// ❌ No cleanup — resources leak
}✅ Correct — Implementing destroy()
async start(ctx: PluginContext) {
this.db = await connectDatabase();
this.fileHandle = fs.openSync('/tmp/data.log');
}
async destroy() {
if (this.db) {
await this.db.close(); // ✅ Close connection
}
if (this.fileHandle) {
fs.closeSync(this.fileHandle); // ✅ Close file
}
}Dependency Management
Declare dependencies to control initialization order:
const MyPlugin: Plugin = {
name: 'com.example.analytics',
version: '1.0.0',
dependencies: ['com.objectstack.engine.objectql'], // Must init first
async init(ctx) {
// Safe to call — ObjectQL is guaranteed to be initialized
const engine = ctx.getService<IDataEngine>('objectql');
ctx.registerService('analytics', new AnalyticsService(engine));
},
};The kernel performs topological sort on the dependency graph. If circular dependencies are detected, ObjectKernel logs a warning (LiteKernel throws).
Complete Plugin Example
// packages/plugins/plugin-audit/src/plugin.ts
import type { Plugin, PluginContext } from '@objectstack/core';
interface AuditEntry {
timestamp: string;
operation: string;
object: string;
recordId?: string;
}
class AuditService {
private log: AuditEntry[] = [];
record(entry: AuditEntry) {
this.log.push(entry);
}
getLog(): AuditEntry[] {
return [...this.log];
}
}
const AuditPlugin: Plugin = {
name: 'com.example.audit',
version: '1.0.0',
type: 'plugin',
dependencies: ['com.objectstack.engine.objectql'],
// Phase 1: Register service and hooks
async init(ctx: PluginContext) {
const auditService = new AuditService();
ctx.registerService('audit', auditService);
ctx.hook('data:afterInsert', async (objectName, _record, result) => {
auditService.record({
timestamp: new Date().toISOString(),
operation: 'insert',
object: objectName,
recordId: result?.id,
});
});
ctx.logger.info('Audit plugin initialized');
},
// Phase 2: Log that audit is active
async start(ctx: PluginContext) {
ctx.logger.info('Audit logging active');
},
// Phase 3: Flush remaining entries (if using external storage)
async destroy() {
// Cleanup if needed
},
};
export default AuditPlugin;Best Practices
1. Keep init() fast — Only register services, don't do heavy work 2. Use start() for connections — Database, network, external services 3. Always implement destroy() — Release resources properly 4. Declare dependencies explicitly — Don't assume service availability 5. Use try/catch in destroy() — Cleanup should never throw 6. Check service availability — Use try/catch or hasService() for optional services 7. Use ctx.logger — Don't use console.log directly 8. Avoid circular dependencies — Design for linear dependency graph 9. Version your plugin — Use semantic versioning 10. Use reverse domain names — e.g., com.example.plugin-name
Testing Lifecycle
import { describe, it, expect } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import MyPlugin from './plugin';
describe('MyPlugin Lifecycle', () => {
it('registers service in init phase', async () => {
const kernel = new LiteKernel({ logger: { level: 'silent' } });
kernel.use(MyPlugin);
await kernel.bootstrap();
const service = kernel.getService('my-service');
expect(service).toBeDefined();
await kernel.shutdown();
});
it('cleans up in destroy phase', async () => {
const kernel = new LiteKernel();
kernel.use(MyPlugin);
await kernel.bootstrap();
// Verify resource is created
const service = kernel.getService('my-service');
expect(service.isConnected()).toBe(true);
await kernel.shutdown();
// Verify resource is cleaned up
expect(service.isConnected()).toBe(false);
});
});Service Registry
Guide for registering and consuming services via the kernel DI container.
Service Registry API
The PluginContext provides three core methods:
// Register a service
ctx.registerService(name: string, instance: any): void;
// Get a service (throws if not found)
ctx.getService<T>(name: string): T;
// Replace an existing service
ctx.replaceService(name: string, instance: any): void;
// Get all services
ctx.getServices(): Map<string, any>;Registration Patterns
Direct Registration
Pass an already-created instance:
async init(ctx: PluginContext) {
const config = { apiKey: process.env.API_KEY };
ctx.registerService('config', config);
}Best for:
- Simple config objects
- Pre-existing instances
- No lazy initialization needed
Factory Registration (ObjectKernel Only)
Let the kernel manage creation and lifecycle:
import { ServiceLifecycle } from '@objectstack/core';
async init(ctx: PluginContext) {
const kernel = ctx.getKernel();
kernel.registerServiceFactory(
'db-pool',
(ctx) => createPool({ connectionString: process.env.DATABASE_URL }),
ServiceLifecycle.SINGLETON,
);
}Best for:
- Lazy initialization (created on first use)
- Lifecycle management
- Dependency injection between services
Service Lifecycles (ObjectKernel Only)
| Lifecycle | Behavior | Use Case |
|---|---|---|
SINGLETON | One instance shared app-wide | Database connections, caches |
TRANSIENT | New instance per getService() call | Stateless utilities, formatters |
SCOPED | One instance per scope (e.g., per request) | Request-scoped contexts, transactions |
Singleton Factory
kernel.registerServiceFactory(
'db-pool',
(ctx) => createPool({ connectionString: process.env.DATABASE_URL }),
ServiceLifecycle.SINGLETON,
);Transient Factory
kernel.registerServiceFactory(
'request-logger',
(ctx) => new RequestLogger(ctx.logger),
ServiceLifecycle.TRANSIENT,
);Scoped Factory
kernel.registerServiceFactory(
'unit-of-work',
(ctx) => new UnitOfWork(ctx.getService('db-pool')),
ServiceLifecycle.SCOPED,
['db-pool'], // Dependencies — resolved before factory executes
);Service Consumption
Basic Usage
async start(ctx: PluginContext) {
const db = ctx.getService<IDataEngine>('objectql');
const cache = ctx.getService<ICacheService>('cache');
// Use services
const result = await db.object('account').find();
await cache.set('accounts', result);
}Optional Services
Check availability before calling:
async start(ctx: PluginContext) {
try {
const realtime = ctx.getService<IRealtimeService>('realtime');
realtime.publish('my-event', data);
} catch {
ctx.logger.debug('Realtime service not available — skipping');
}
}Service Replacement
Wrap an existing service with instrumentation:
async start(ctx: PluginContext) {
const existingCache = ctx.getService('cache');
const instrumentedCache = new InstrumentedCache(existingCache);
ctx.replaceService('cache', instrumentedCache);
}Well-Known Service Keys
| Service Key | Plugin Name | Package |
|---|---|---|
objectql | com.objectstack.engine.objectql | @objectstack/objectql |
driver.* | com.objectstack.driver.* | @objectstack/driver-* |
auth | com.objectstack.auth | @objectstack/plugin-auth |
rest | com.objectstack.rest | @objectstack/rest |
metadata | com.objectstack.metadata | @objectstack/metadata |
realtime | com.objectstack.realtime | @objectstack/service-realtime |
cache | com.objectstack.cache | @objectstack/service-cache |
Core Fallback Injection
ObjectKernel auto-injects in-memory fallbacks for core-criticality services not registered by any plugin during Phase 1.
Phase 1: init() completes for all plugins
↓
Kernel checks ServiceRequirementDef:
'metadata' → core → auto-inject InMemoryMetadataService if missing
'cache' → core → auto-inject InMemoryCache if missing
'queue' → core → auto-inject InMemoryQueue if missing
'objectql' → required → ERROR if missing (no fallback)
'realtime' → optional → skip, plugins should check availability
↓
Phase 2: start() begins — all core services availableService Criticality Levels
| Level | Behavior |
|---|---|
required | Kernel throws if missing — system cannot start |
core | Auto-injected in-memory fallback if no plugin provides it |
optional | Silently skipped — plugins must check before use |
Incorrect vs Correct
❌ Incorrect — Getting Service in init()
async init(ctx: PluginContext) {
const db = ctx.getService('objectql'); // ❌ May not exist yet
ctx.registerService('analytics', new Analytics(db));
}✅ Correct — Getting Service in start()
async init(ctx: PluginContext) {
// Just register placeholder or factory
ctx.registerService('analytics', null);
}
async start(ctx: PluginContext) {
const db = ctx.getService('objectql'); // ✅ Safe — all services registered
const analytics = new Analytics(db);
ctx.replaceService('analytics', analytics);
}❌ Incorrect — No Error Handling for Optional Service
async start(ctx: PluginContext) {
const realtime = ctx.getService('realtime'); // ❌ Throws if not available
realtime.publish('event', data);
}✅ Correct — Error Handling for Optional Service
async start(ctx: PluginContext) {
try {
const realtime = ctx.getService('realtime');
realtime.publish('event', data);
} catch {
ctx.logger.debug('Realtime service not available'); // ✅ Graceful fallback
}
}❌ Incorrect — Duplicate Registration
async init(ctx: PluginContext) {
ctx.registerService('cache', new MemoryCache());
ctx.registerService('cache', new RedisCache()); // ❌ Overwrites silently
}✅ Correct — Use replaceService() for Updates
async init(ctx: PluginContext) {
ctx.registerService('cache', new MemoryCache());
}
async start(ctx: PluginContext) {
const oldCache = ctx.getService('cache');
ctx.replaceService('cache', new RedisCache(oldCache)); // ✅ Explicit replacement
}Service Naming Conventions
1. Use lowercase, hyphen-separated names — e.g., db-pool, request-logger 2. Use namespaces for multiple instances — e.g., driver.postgres, driver.mysql 3. Use descriptive names — e.g., auth-service not as 4. Avoid abbreviations — e.g., database not db (unless well-known like db-pool)
Testing Service Registration
import { describe, it, expect } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import MyPlugin from './plugin';
describe('Service Registration', () => {
it('registers service in init phase', async () => {
const kernel = new LiteKernel();
kernel.use(MyPlugin);
await kernel.bootstrap();
const service = kernel.getService('my-service');
expect(service).toBeDefined();
expect(service.name).toBe('MyService');
await kernel.shutdown();
});
it('throws when service not found', async () => {
const kernel = new LiteKernel();
await kernel.bootstrap();
expect(() => kernel.getService('non-existent')).toThrow();
await kernel.shutdown();
});
});Best Practices
1. Register in init() — All service registration in Phase 1 2. Consume in start() — Use getService() only in Phase 2 3. Use try/catch for optional services — Don't assume availability 4. Use descriptive service keys — Clear, namespaced names 5. Declare dependencies — Let kernel handle initialization order 6. Use factories for lazy init — Defer expensive creation 7. Use scoped services for requests — Request-specific contexts 8. Don't register null — Register a real instance or factory 9. Use replaceService() explicitly — Don't re-register 10. Document your services — What they do, what they depend on