Appearance
Extension Development Guide
This guide covers how to create, develop, and deploy custom extensions for the ODP API using @odp/extensions-sdk.
Prerequisites
Install the SDK in your project:
bash
# In a pnpm workspace — install at root, extensions reference via workspace:*
pnpm add -D @odp/extensions-sdk
# Standalone extension project — install directly
pnpm add -D @odp/extensions-sdk
# Before the SDK is published to a registry — install from local path
pnpm add -D @odp/extensions-sdk@file:/path/to/odp/cli/extensions-sdkThe SDK provides:
- CLI (
odp-extension) — scaffold, build, and manage extensions - Type helpers (
defineHook,defineEndpoint) — identity functions for TypeScript inference - Build toolchain — tsup + typescript bundled as dependencies (extensions don't install these directly)
Quick Start
Scaffold a new extension:
bash
# Using the SDK CLI directly
odp-extension create -n my-extension -t bundle
# Or via the ODP server CLI
odp extension:create -n my-extension -t bundleThen install dependencies and start dev mode:
bash
cd extensions/my-extension
pnpm install
pnpm run devThe extension is now watching for changes and rebuilding automatically.
SDK CLI Commands
create
Scaffold a new extension project.
bash
odp-extension create -n <name> -t <type>| Flag | Required | Default | Description |
|---|---|---|---|
-n, --name | Yes | — | Extension folder name (also used as package name and ID) |
-t, --type | No | bundle | Extension type: hook, endpoint, or bundle |
The command creates under EXTENSIONS_PATH (default ./extensions):
package.jsonwithodp-extensionmetadata and build scriptstsconfig.jsonconfigured for ES2022 + bundler resolution- Source file templates based on the chosen type
Example:
bash
# Create a hook-only extension
odp-extension create -n audit-logger -t hook
# Create an endpoint-only extension
odp-extension create -n health-check -t endpoint
# Create a bundle with both hooks and endpoints
odp-extension create -n analytics -t bundlebuild
Build one or all extensions using tsup (bundled in the SDK).
bash
# Build current directory (run from inside an extension)
odp-extension build
# Build all extensions in EXTENSIONS_PATH
odp-extension build
# Build a specific extension
odp-extension build my-extension
# Watch mode
odp-extension build --watchThe build process:
- Reads
package.jsonforodp-extensionmetadata - For single extensions (hook/endpoint): builds
src/index.ts→dist/index.js - For bundles: auto-generates a virtual entry from
entriesinpackage.json, builds →dist/index.js, cleans up the generated file - Output: ESM format, self-contained (SDK helpers bundled inline)
add
Add a new entry to an existing bundle extension. Run from inside the extension directory.
bash
odp-extension add -n <name> -t <type>| Flag | Required | Description |
|---|---|---|
-n, --name | Yes | Entry name |
-t, --type | Yes | Entry type: hook or endpoint |
Example:
bash
cd extensions/analytics
odp-extension add -n webhook-handler -t endpoint
odp-extension add -n data-enricher -t hookThis updates package.json entries and creates the source file at src/{type}s/{name}/index.ts.
Server CLI Commands
The ODP server also provides extension commands that delegate to the SDK:
bash
# Scaffold (same as odp-extension create)
odp extension:create -n <name> -t <type>
# Build all extensions (scans EXTENSIONS_PATH, installs deps if needed)
odp extension:build [-n <name>]Extension Types
Hook
Listens to lifecycle events (item CRUD, server init, scheduled jobs). Does not expose HTTP routes.
my-hook/
├── package.json
├── tsconfig.json
└── src/
└── index.ts ← defineHook(...)Endpoint
Registers custom HTTP routes on the Fastify instance. Does not listen to lifecycle events.
my-endpoint/
├── package.json
├── tsconfig.json
└── src/
└── index.ts ← defineEndpoint(...)Routes are mounted at /{extension-id}/. For example, an extension with ID health-check defining router.get('/ping', ...) is accessible at /health-check/ping.
Bundle
Combines hooks and endpoints in a single package. This is the default type and the recommended approach for most extensions.
my-bundle/
├── package.json
├── tsconfig.json
└── src/
├── hooks/
│ └── index.ts ← defineHook(...)
└── endpoints/
└── index.ts ← defineEndpoint(...)No
src/index.tsneeded. The build tool auto-generates a virtual entry point from theentriesarray inpackage.json. You only write the individual hook/endpoint source files.
Bundles can have multiple entries of the same type. Use odp-extension add to add more entries:
bash
odp-extension add -n analytics-hooks -t hook
odp-extension add -n webhook-handler -t endpointEach entry gets its own directory: src/hooks/{name}/index.ts or src/endpoints/{name}/index.ts.
Bundle endpoints are mounted at /{entry-name}/, where entry-name comes from the entries array in package.json. This allows a single bundle to expose multiple independent endpoint groups, each with its own route prefix.
Package Metadata
Every extension must have an odp-extension block in package.json. This is how the Extension Manager discovers and classifies extensions.
Hook or Endpoint
json
{
"name": "my-hook",
"version": "1.0.0",
"type": "module",
"odp-extension": {
"id": "my-hook",
"type": "hook"
},
"scripts": {
"build": "odp-extension build",
"dev": "odp-extension build --watch"
},
"devDependencies": {
"@odp/extensions-sdk": "*"
}
}Bundle
json
{
"name": "analytics",
"version": "1.0.0",
"type": "module",
"odp-extension": {
"id": "analytics",
"type": "bundle",
"entries": [
{ "type": "hook", "name": "analytics-hooks", "source": "src/hooks/index.ts" },
{ "type": "endpoint", "name": "analytics-endpoints", "source": "src/endpoints/index.ts" }
]
},
"scripts": {
"build": "odp-extension build",
"dev": "odp-extension build --watch"
},
"devDependencies": {
"@odp/extensions-sdk": "*"
}
}Note: Extensions only need
@odp/extensions-sdkas a devDependency. The SDK ships withtsupandtypescript— you don't install these separately.
| Field | Type | Description |
|---|---|---|
odp-extension.id | string | Unique extension identifier (matches folder name) |
odp-extension.type | "hook" | "endpoint" | "bundle" | Extension type |
odp-extension.entries | array | Bundle only — list of sub-entries with type, name, and source |
odp-extension.entries[].source | string | Path to the entry's source file (e.g. src/hooks/index.ts). If omitted, the build tool guesses by convention. |
odp-extension.path | string | Optional custom entry point (default: dist/index.js) |
Type-Safe Helpers
Import from @odp/extensions-sdk for full TypeScript support:
typescript
import { defineHook } from '@odp/extensions-sdk';
import { defineEndpoint } from '@odp/extensions-sdk';These are identity functions that enable TypeScript inference — no runtime overhead.
Build & Resolution
Extensions use @odp/extensions-sdk as a devDependency. The SDK provides:
- TypeScript types and the
defineHook/defineEndpointidentity functions - The
odp-extensionCLI for building (wraps tsup programmatically) tsupandtypescriptas bundled dependencies
When building, the SDK's identity functions are bundled inline (2 lines of JS, zero overhead) — no external resolution needed at runtime. The output is a single self-contained dist/index.js (ESM format).
Important: Do NOT add
--external @odp/extensions-sdkto your build. The SDK helpers are bundled into the extension output so it is fully self-contained — similar to how Directus extensions work.
Installation in Workspaces
In a pnpm workspace, install the SDK once at the project root. Each extension references it via workspace protocol:
my-project/
├── package.json ← "devDependencies": { "@odp/extensions-sdk": "..." }
├── pnpm-workspace.yaml ← packages: ["extensions/*"]
└── extensions/
├── app-crm/
│ └── package.json ← "devDependencies": { "@odp/extensions-sdk": "workspace:*" }
└── audit-log/
└── package.json ← "devDependencies": { "@odp/extensions-sdk": "workspace:*" }For standalone extensions (not in a workspace), install the SDK directly in the extension's package.json.
Writing Hooks
Basic Structure
typescript
import { defineHook } from '@odp/extensions-sdk';
export default defineHook((context, meta) => {
const { filter, action, init, schedule } = context;
const { services, database, logger, env, getSchema, emitter, validateAppAccess } = meta;
// Register event listeners here...
});Event Types
action — Fire-and-Forget
Runs asynchronously after an operation completes. All handlers run in parallel. Errors are caught and logged — they do not affect the original operation.
typescript
context.action('items.create', async (meta, ctx) => {
// meta.collection, meta.key, meta.payload — event-specific data
// ctx.database — Knex instance
// ctx.schema — current schema snapshot
// ctx.accountability — current user or null
(logger as any).info({ collection: meta.collection }, 'Item created');
});Use cases: audit logging, sending notifications, syncing to external systems.
filter — Synchronous Pipeline
Runs sequentially before an operation. Each handler receives the output of the previous one. Return the (possibly modified) payload. Throwing an error aborts the operation.
typescript
context.filter('items.create', async (payload, meta, ctx) => {
if (meta.collection === 'articles') {
payload.status = payload.status ?? 'draft';
}
return payload; // must return payload
});Use cases: validation, data enrichment, default values, access control.
init — Server Initialization
Runs sequentially during server startup. Use for one-time setup.
typescript
context.init('server.start', async (meta) => {
(logger as any).info('Extension initialized');
});schedule — Cron Jobs
Register recurring tasks using standard 5-field cron expressions. Multi-instance safe via SynchronizedClock.
typescript
context.schedule('0 */6 * * *', async () => {
// Runs every 6 hours
const schema = await getSchema();
const items = await database('stale_items')
.where('updated_at', '<', new Date(Date.now() - 86400000));
// ... cleanup logic
});Using Services in Hooks
Services let you interact with ODP through the business logic layer instead of raw SQL:
typescript
import { defineHook } from '@odp/extensions-sdk';
export default defineHook((context, { services, database, getSchema }) => {
// Auto-create a related record when an article is published
context.action('items.update', async (meta, ctx) => {
if (meta.collection !== 'articles') return;
const schema = await getSchema();
const itemsService = new services.ItemsService('articles', {
knex: database,
accountability: ctx.accountability,
schema,
});
const article = await itemsService.readOne(meta.keys[0]);
if (article.status !== 'published') return;
// Create a notification for the author
const notifyService = new services.NotificationsService({
knex: database,
accountability: null, // system-level, bypass permissions
schema,
});
await notifyService.createOne({
recipient: article.author,
subject: 'Article Published',
message: `Your article "${article.title}" is now live.`,
});
});
// Use services in scheduled jobs
context.schedule('0 3 * * *', async () => {
const schema = await getSchema();
const usersService = new services.UsersService({
knex: database,
accountability: null,
schema,
});
// Deactivate users who haven't logged in for 90 days
const staleUsers = await usersService.readByQuery({
filter: { last_access: { _lt: new Date(Date.now() - 90 * 86400000).toISOString() } },
fields: ['id'],
});
for (const user of staleUsers) {
await usersService.updateOne(user.id, { status: 'suspended' });
}
});
});Event Matching Rules
| Pattern | Matches |
|---|---|
'items.create' | Exact match |
'items.*' | Any event starting with items. (items.create, items.update, etc.) |
'*' | All events |
Collection-specific events are also supported: when an event articles.items.create is emitted, handlers registered for items.create will match.
Common Events
| Event | Type | Meta Fields |
|---|---|---|
items.create | action/filter | collection, key, payload |
items.update | action/filter | collection, keys, payload |
items.delete | action/filter | collection, keys |
items.read | action/filter | collection, query |
server.start | init | — |
server.stop | init | — |
Writing Endpoints
Basic Structure
typescript
import { defineEndpoint } from '@odp/extensions-sdk';
export default defineEndpoint((router, context) => {
const { services, database, logger, env, getSchema, emitter, validateAppAccess } = context;
router.get('/ping', async (_request, reply) => {
return reply.send({ pong: true });
});
router.post('/process', async (request, reply) => {
const body = request.body as Record<string, unknown>;
// ... business logic
return reply.send({ data: { status: 'ok' } });
});
});The router is a scoped Fastify instance. All standard Fastify methods are available: get, post, put, patch, delete.
Using Services in Endpoints
Use service constructors from context.services for type-safe, permission-aware access:
typescript
router.get('/stats', async (request, reply) => {
const schema = await getSchema();
const accountability = (request as any).accountability;
const itemsService = new services.ItemsService('articles', {
knex: database,
accountability,
schema,
});
const items = await itemsService.readByQuery({ aggregate: { count: ['*'] } });
return reply.send({ data: items });
});You can also use raw Knex queries when services are overkill:
typescript
router.get('/raw-count', async (_request, reply) => {
const count = await database('articles')
.count('* as total')
.first();
return reply.send({ data: { total: count?.total ?? 0 } });
});Permission Checking
Use validateAppAccess to enforce role-based access:
typescript
router.get('/admin-stats', async (request, reply) => {
const accountability = (request as any).accountability;
await validateAppAccess(
accountability,
'my-extension', // module name
'read', // action
null, // collection scope (null = all)
database,
);
// Only reachable if the user has permission
const stats = await database('analytics_events').count('* as total').first();
return reply.send({ data: stats });
});Runtime Context Reference
Both hooks and endpoints receive an ApiExtensionContext object with these properties:
| Property | Type | Description |
|---|---|---|
services | ExtensionServices | All ODP service constructors (see below) |
database | Knex | Database connection (Knex query builder) |
logger | Logger | Pino logger scoped to the extension |
env | Record<string, unknown> | Environment variables |
getSchema() | () => Promise<SchemaOverview> | Returns the current database schema (cached, queries real DB) |
emitter | ExtensionEmitter | Event emitter for subscribing to filter/action events |
validateAppAccess() | Function | Check app-level permissions for the current user |
EventContext (passed to filter/action handlers)
| Property | Type | Description |
|---|---|---|
database | Knex | Database connection |
schema | SchemaOverview | Schema snapshot at event time |
accountability | Accountability | null | Current user context (null for system operations) |
Available Services
Extensions receive service constructors (not instances). Instantiate them with { knex, accountability, schema }:
typescript
const schema = await getSchema();
const itemsService = new services.ItemsService('my_collection', {
knex: database,
accountability: ctx.accountability, // from EventContext
schema,
});| Service | Collection/Purpose |
|---|---|
ItemsService | Generic CRUD for any collection |
UsersService | odp_users — user management |
RolesService | odp_roles — role management |
FilesService | odp_files — file upload/management |
AssetsService | File asset transformation/delivery |
CollectionsService | Schema — create/update/delete collections |
FieldsService | Schema — create/update/delete fields |
RelationsService | Schema — manage relations (M2O, O2M, M2M, M2A) |
PermissionsService | odp_permissions — CRUD permissions |
PoliciesService | odp_policies — access policies |
ActivityService | odp_activity — activity log |
RevisionsService | odp_revisions — revision tracking |
VersionsService | odp_versions — content versioning |
CommentsService | odp_comments — item comments |
NotificationsService | odp_notifications — user notifications |
PresetsService | odp_presets — saved filter/layout presets |
SettingsService | odp_settings — global settings |
TranslationsService | odp_translations — custom translations |
SharesService | odp_shares — public shares |
MailService | Send emails via configured transport |
AuthService | Authentication (login, refresh, logout) |
SchemaService | Schema snapshot/diff/apply |
ImportExportService | Data import/export |
PayloadService | Payload transformation (hashing, JSON, etc.) |
MetaService | Collection metadata (count, etc.) |
UtilsService | Utility operations (hash, UUID, etc.) |
GraphQLService | GraphQL query execution |
WebSocketService | WebSocket connection management |
AppPermissionsService | App-level module permissions |
ExtensionSettingsService | Extension configuration storage |
SubTokenService | Sub-token management |
UserProvidersService | SSO provider linking |
ImpersonationService | User impersonation |
ProviderSettingsService | Auth/storage provider configuration |
Permission enforcement: Services enforce permissions based on the accountability object you pass. Use null for system-level (bypass all checks) or pass the request's accountability for user-level access.
Development Workflow
1. Create
bash
odp-extension create -n my-feature -t bundle
cd extensions/my-feature
pnpm install2. Develop
Start the watcher:
bash
pnpm run devIf the server has EXTENSIONS_AUTO_RELOAD=true, changes are picked up automatically (debounced 500ms). Otherwise, restart the server after each build.
3. Build for Production
bash
# Build current extension
odp-extension build
# Build all extensions in EXTENSIONS_PATH
odp-extension build
# Build a specific extension by name
odp-extension build my-featureOutput goes to dist/index.js (ESM format).
4. Register
Extensions are auto-discovered by the Extension Manager when they have a valid package.json with odp-extension metadata. To manually register or enable/disable:
bash
# Via API
curl -X PATCH http://localhost:8055/extensions/<id> \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"enabled": true}'Or insert directly into the database:
sql
INSERT INTO odp_extensions (id, enabled, folder, source)
VALUES (gen_random_uuid(), true, 'my-feature', 'local');5. Hot Reload
Extensions support hot reload without server restart:
- During development: Set
EXTENSIONS_AUTO_RELOAD=true— the file watcher triggers reload on changes - Via API:
PATCH /extensions/:idwith{"enabled": true}triggersextensionManager.reloadExtension(id) - Disable:
PATCH /extensions/:idwith{"enabled": false}unloads hooks and routes immediately
Complete Example: Audit Logger Bundle
bash
odp-extension create -n audit-logger -t bundle
cd extensions/audit-logger
pnpm installThis generates a bundle with entries in package.json:
json
{
"odp-extension": {
"id": "audit-logger",
"type": "bundle",
"entries": [
{ "type": "hook", "name": "audit-logger-hooks", "source": "src/hooks/index.ts" },
{ "type": "endpoint", "name": "audit-logger-endpoints", "source": "src/endpoints/index.ts" }
]
}
}src/hooks/index.ts — Log all item mutations:
typescript
import { defineHook } from '@odp/extensions-sdk';
export default defineHook((context, { database, logger }) => {
const collections = ['articles', 'products', 'orders'];
context.action('items.create', async (meta, ctx) => {
if (!collections.includes(meta.collection as string)) return;
await database('audit_log').insert({
action: 'create',
collection: meta.collection,
item_id: meta.key,
user_id: ctx.accountability?.user ?? 'system',
timestamp: new Date(),
});
});
context.action('items.update', async (meta, ctx) => {
if (!collections.includes(meta.collection as string)) return;
for (const key of meta.keys as string[]) {
await database('audit_log').insert({
action: 'update',
collection: meta.collection,
item_id: key,
user_id: ctx.accountability?.user ?? 'system',
timestamp: new Date(),
});
}
});
context.action('items.delete', async (meta, ctx) => {
if (!collections.includes(meta.collection as string)) return;
for (const key of meta.keys as string[]) {
await database('audit_log').insert({
action: 'delete',
collection: meta.collection,
item_id: key,
user_id: ctx.accountability?.user ?? 'system',
timestamp: new Date(),
});
}
});
});src/endpoints/index.ts — Query audit logs:
typescript
import { defineEndpoint } from '@odp/extensions-sdk';
export default defineEndpoint((router, { database, validateAppAccess }) => {
router.get('/logs', async (request, reply) => {
const accountability = (request as any).accountability;
await validateAppAccess(accountability, 'audit-logger', 'read', null, database);
const query = request.query as Record<string, string>;
const limit = Math.min(parseInt(query.limit ?? '50', 10), 200);
const offset = parseInt(query.offset ?? '0', 10);
const logs = await database('audit_log')
.orderBy('timestamp', 'desc')
.limit(limit)
.offset(offset);
const [{ total }] = await database('audit_log').count('* as total');
return reply.send({
data: logs,
meta: { total_count: total, filter_count: logs.length },
});
});
});No
src/index.tsneeded —odp-extension buildauto-generates the entry point fromentriesinpackage.json.
Environment Variables
| Variable | Default | Description |
|---|---|---|
EXTENSIONS_PATH | ./extensions | Root directory for extension folders |
EXTENSIONS_MUST_LOAD | "" | Comma-separated extension IDs that must load (server fails on error) |
EXTENSIONS_AUTO_RELOAD | false | Watch for file changes and auto-reload extensions |
Extension Loading Order
- Server starts, runs database migrations
extensionManager.initialize(app, knex)stores Fastify and DB referencesextensionManager.loadAll()scansEXTENSIONS_PATH- For each enabled extension:
- Resolves entry point:
odp-extension.path>package.json.main>dist/index.js - Dynamically imports the module
- Detects type from exports (
hooks/endpoints/default) - Registers hooks with the event emitter and/or endpoints with Fastify
- Resolves entry point:
- If
EXTENSIONS_AUTO_RELOAD=true, starts the file watcher - Server emits
server.startaction event