Skip to content

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

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

Then install dependencies and start dev mode:

bash
cd extensions/my-extension
pnpm install
pnpm run dev

The 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>
FlagRequiredDefaultDescription
-n, --nameYesExtension folder name (also used as package name and ID)
-t, --typeNobundleExtension type: hook, endpoint, or bundle

The command creates under EXTENSIONS_PATH (default ./extensions):

  • package.json with odp-extension metadata and build scripts
  • tsconfig.json configured 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 bundle

build

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

The build process:

  1. Reads package.json for odp-extension metadata
  2. For single extensions (hook/endpoint): builds src/index.tsdist/index.js
  3. For bundles: auto-generates a virtual entry from entries in package.json, builds → dist/index.js, cleans up the generated file
  4. 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>
FlagRequiredDescription
-n, --nameYesEntry name
-t, --typeYesEntry type: hook or endpoint

Example:

bash
cd extensions/analytics
odp-extension add -n webhook-handler -t endpoint
odp-extension add -n data-enricher -t hook

This 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.ts needed. The build tool auto-generates a virtual entry point from the entries array in package.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 endpoint

Each 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-sdk as a devDependency. The SDK ships with tsup and typescript — you don't install these separately.

FieldTypeDescription
odp-extension.idstringUnique extension identifier (matches folder name)
odp-extension.type"hook" | "endpoint" | "bundle"Extension type
odp-extension.entriesarrayBundle only — list of sub-entries with type, name, and source
odp-extension.entries[].sourcestringPath to the entry's source file (e.g. src/hooks/index.ts). If omitted, the build tool guesses by convention.
odp-extension.pathstringOptional 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/defineEndpoint identity functions
  • The odp-extension CLI for building (wraps tsup programmatically)
  • tsup and typescript as 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-sdk to 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

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

EventTypeMeta Fields
items.createaction/filtercollection, key, payload
items.updateaction/filtercollection, keys, payload
items.deleteaction/filtercollection, keys
items.readaction/filtercollection, query
server.startinit
server.stopinit

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:

PropertyTypeDescription
servicesExtensionServicesAll ODP service constructors (see below)
databaseKnexDatabase connection (Knex query builder)
loggerLoggerPino logger scoped to the extension
envRecord<string, unknown>Environment variables
getSchema()() => Promise<SchemaOverview>Returns the current database schema (cached, queries real DB)
emitterExtensionEmitterEvent emitter for subscribing to filter/action events
validateAppAccess()FunctionCheck app-level permissions for the current user

EventContext (passed to filter/action handlers)

PropertyTypeDescription
databaseKnexDatabase connection
schemaSchemaOverviewSchema snapshot at event time
accountabilityAccountability | nullCurrent 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,
});
ServiceCollection/Purpose
ItemsServiceGeneric CRUD for any collection
UsersServiceodp_users — user management
RolesServiceodp_roles — role management
FilesServiceodp_files — file upload/management
AssetsServiceFile asset transformation/delivery
CollectionsServiceSchema — create/update/delete collections
FieldsServiceSchema — create/update/delete fields
RelationsServiceSchema — manage relations (M2O, O2M, M2M, M2A)
PermissionsServiceodp_permissions — CRUD permissions
PoliciesServiceodp_policies — access policies
ActivityServiceodp_activity — activity log
RevisionsServiceodp_revisions — revision tracking
VersionsServiceodp_versions — content versioning
CommentsServiceodp_comments — item comments
NotificationsServiceodp_notifications — user notifications
PresetsServiceodp_presets — saved filter/layout presets
SettingsServiceodp_settings — global settings
TranslationsServiceodp_translations — custom translations
SharesServiceodp_shares — public shares
MailServiceSend emails via configured transport
AuthServiceAuthentication (login, refresh, logout)
SchemaServiceSchema snapshot/diff/apply
ImportExportServiceData import/export
PayloadServicePayload transformation (hashing, JSON, etc.)
MetaServiceCollection metadata (count, etc.)
UtilsServiceUtility operations (hash, UUID, etc.)
GraphQLServiceGraphQL query execution
WebSocketServiceWebSocket connection management
AppPermissionsServiceApp-level module permissions
ExtensionSettingsServiceExtension configuration storage
SubTokenServiceSub-token management
UserProvidersServiceSSO provider linking
ImpersonationServiceUser impersonation
ProviderSettingsServiceAuth/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 install

2. Develop

Start the watcher:

bash
pnpm run dev

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

Output 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/:id with {"enabled": true} triggers extensionManager.reloadExtension(id)
  • Disable: PATCH /extensions/:id with {"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 install

This 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.ts needed — odp-extension build auto-generates the entry point from entries in package.json.


Environment Variables

VariableDefaultDescription
EXTENSIONS_PATH./extensionsRoot directory for extension folders
EXTENSIONS_MUST_LOAD""Comma-separated extension IDs that must load (server fails on error)
EXTENSIONS_AUTO_RELOADfalseWatch for file changes and auto-reload extensions

Extension Loading Order

  1. Server starts, runs database migrations
  2. extensionManager.initialize(app, knex) stores Fastify and DB references
  3. extensionManager.loadAll() scans EXTENSIONS_PATH
  4. 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
  5. If EXTENSIONS_AUTO_RELOAD=true, starts the file watcher
  6. Server emits server.start action event

ODP Internal API Documentation