Plugin and extension API
Plugins are modules with kind: 'plugin', usually third-party packages. They use exactly the same contract as first-party modules. This page lists every extension point; Building modules shows how to use them step by step.
Distribution
npm package. Add it to the dependencies of apps/api (server half) and apps/web (web half), then rebuild.
- With Docker, rebuild the image (see Deploying Modulix).
- The web half must be part of the web build, because module UIs are bundled and type-checked with the app.
Drop-in directory. Put the package in plugins/. Containers can instead mount a directory and point MODULIX_MODULE_DIRS at it; that covers the server half only.
Once deployed, a platform administrator installs the plugin in Administration → Modules, and each organization enables it.
Server extension points
Action hooks: react to events
Action hooks notify listeners that something happened. Register handlers in hooks(bus) of defineServerModule:
hooks(bus) {
bus.on('user.created', async ({ userId, email }) => { … }, { priority: 5 })
}
| Hook | Payload |
|---|---|
module.installed / module.upgraded / module.uninstalled | { moduleId, version } / { moduleId, from, to } / { moduleId } |
module.enabled / module.disabled | { tenantId, moduleId } |
tenant.created | { tenantId, slug } |
user.created / user.loggedIn | { userId, email } / { userId, tenantId } |
user.added / user.removed | { tenantId, userId } when a user joins or leaves a tenant (the account itself is kept) |
settings.changed | { tenantId, moduleId, scope, keys } |
notification.send | NotificationRequest: asks for a notification of a declared type (see Notifications) |
entity.created / entity.updated / entity.deleted | { tenantId, entity, id, record } for records of declared entities; updates add previous and changes (see Extensible records) |
Filter hooks: transform a value
Filter hooks pass a value through every handler; each may transform it.
bus.filter('menu.build', (items, ctx) => items.filter((i) => i.id !== 'admin.audit'))
| Hook | Value |
|---|---|
menu.build | MenuItem[] before the permission filter in /me/context |
dashboard.widgets | string[] ids of widgets contributed server-side |
core.user.teammates | { tenantId, userId, userIds }: the people a member works with, for permissions granted at the team scope (the Organization Structure module adds the members of the person's units and of the units below) |
entity.lookup | { entity, ids?, query?, limit?, records }: owners add { id, label } of their records (by id, or matching a search; limit is about 20 by default), checking read access. Custom fields, pickers (GET /api/v1/search/lookup?entity=&q= or &ids=) and reference fields use it |
entity.merged (action) | { tenantId, entity, fromId, toId }: two records were merged (Contacts' merge). Modules keeping data about records move fromId's data to toId (keeping toId's own values) before fromId is deleted. Activities, Documents, Custom fields, Leads and Automation do |
email.deliver | { tenantId, mail, via }: an email of a module (MAIL) to deliver; a module with the organization's provider sends it and sets via (Notifications does) |
lead.convert | { dryRun, leadId, contactId, companyId, ownerId, lead, opportunity, available, opportunityId }: a lead being converted asks for a deal. The CRM module sets available (dry run) or creates it from opportunity and sets opportunityId, inside the conversion's transaction |
entity.inUse | { entity, id, uses }: before deleting a record, its owner asks which records of other modules refer to it; modules keeping references add { entity, label, count } (label is their records' plural name). The owner refuses with 409 inUse when there are any. entityExtensions.uses(hooks, entity, id) asks; RecordStore does it for you |
entity.extend / entity.filter / entity.search | Called by owners through entityExtensions.extend(hooks, entity, items), .filter(hooks, entity, query) (query parameters with a dot, such as cf.tier) and .search(hooks, entity, q). Other modules add data to records (customFields), narrow lists and add search matches |
entity.filterFields | { entity, fields }: fields other modules add to a record type's filter builder and exports (custom fields as cf.<key>) |
entity.saveExtra | { entity, id, values, errors }: imported values of other modules (cf.<key> columns, as text); handlers save them and report problems |
entities.build | The tenant's record types (MODULES.entities()): a module may add fields that depend on its data to its own entities, for example each question of its forms as answers.<name>. Return new objects; never remove declared fields |
Reserved for upcoming features: entity.beforeSave, entity.afterSave.
Declaring your own hooks
Augment the SDK interfaces so emitters and listeners stay type-checked across packages:
declare module '@modulix/sdk' {
interface ActionHooks { 'invoices.invoice.paid': { tenantId: string; invoiceId: string; amount: string } }
interface FilterHooks { 'invoices.pdf.footer': string }
}
Hook semantics
- Handlers run in ascending
priorityorder (default 10). - Handlers that need Nest services can be registered from a provider's
onModuleInitthrough theHOOKStoken. Pass{ owner: '<module id>' }so they get the same organization gating ashooks(bus). - Handlers registered by a module run only for organizations that enabled it. For events without an organization, they run while the module is installed.
- Action handlers are awaited in sequence. An exception propagates to the emitter, so catch errors you don't want to surface.
Other server points
| Point | How |
|---|---|
| HTTP endpoints | Controllers in your Nest module → /api/v1/<id>/… |
| Permissions | manifest.permissions + defaultGrants. The Read-only system role gets every *.read permission unless defaultGrants.readonly says otherwise. Every refused request (403) is written to the audit log as access.denied |
| Record rules | Declare a permission scoped: true and roles may grant it for own, team or all records (stored as key:own / key:team). Enforce it with the RECORD_ACCESS token: owners(permission) (null means all records; otherwise the owner ids to filter by), allows(permission, ownerId), scope(permission). In the web app, use useCanOnRecord(permission, ownerId) or <Can permission ownerId>. The Contacts module is the reference |
| Menu entries | manifest.menu (group: 'main', 'admin' or a custom group) |
| Settings | manifest.settings.tenant / .user (zod) |
| Tables and migrations | defineTable + migrations/pg and migrations/mysql |
| Lifecycle | onInstall, onUpgrade, onUninstall, onEnable(tenantId), onDisable(tenantId) |
| Imports and exports | DATA_TRANSFER.register({ entity, fields, create(values), update(id, values), match?(values), export(query, page, pageSize) }): the core handles files (CSV, Excel), mapping, background jobs, external ids and error reports |
| List filters | Validate ?filter= with parseFilter(json, fields) (@modulix/sdk) and query with filterSql(hooks, entity, group, columns, idColumn, dateFields) (@modulix/sdk-server); offer other modules' fields with the entity.filterFields hook |
| Audit | AUDIT.record({ moduleId, action, entityType, entityId, data, before?, after? }). For a declared record, emit the entity.* hook first and the entry gets the values before and after. IP address, browser and user come from the request |
| Core services | Tokens DB, HOOKS, SETTINGS, AUDIT, MODULES, ACTIONS from @modulix/sdk-server (MODULES.entities() lists the entities of enabled modules) |
| Languages | defineServerModule({ messages }); I18N token → preferences(tenantId, userId) (language, time zone, date format, currency) and t(locale, 'module:key', params) |
| Module actions | manifest.actions + ACTIONS.register(key, handler, { owner }); see Module actions |
| Notifications | manifest.notifications + the notification.send hook; see Notifications |
| Stored credentials | SECRETS token → encrypt(plain) / decrypt(sealed) (AES-256-GCM with SECRETS_KEY); never return decrypted values to clients |
MAIL token → send({ to, cc?, replyTo?, subject, text, html, attachments? }): the organization's own provider (Notifications module, through the email.deliver filter) when connected, else the deployment's SMTP_URL, else logged. Resolves with { via } | |
renderBusinessDocument(doc) lays out a quotation, order or invoice (header, customer, lines table repeated across pages, totals, notes, footer, page numbers) from formatted values; PdfDocument writes pages of text, lines and rectangles (standard Helvetica, WinAnsi: Western European languages and €). No dependency | |
| Books | LedgerService from @modulix/module-accounting/server (import AccountingModule; require accounting): record({ date, description, source, lines: [{ role or account, debit or credit, partnerId? }] }) posts a balanced entry, reverseSource(entity, id) reverses a document's entries. Accounts are found by role (receivable, payable, bank, tax_collected, ...) |
| File storage | STORAGE token → put(key, stream, { size }), get, delete, exists; local disk or S3 per deployment. Prefix keys with your module id |
| Signed links | SECRETS.sign(data, ttl) / verify(token): short-lived tokens, for example for download URLs |
| Uploads | application/octet-stream request bodies reach handlers as the raw stream: uploadStream(req) then receive(stream, maxBytes, length) (a hashed temporary file, 413 past the limit) from @modulix/sdk-server; api.upload(path, file, { onProgress }) in the web SDK |
| Public module routes | @Public() routes called without a tenant (a signed link, a public form) skip the module check and must authorize themselves: find the tenant from the token, check MODULES.isEnabled(id, tenantId), and run tenant code in requestContext.run({ tenantId, … }) |
| Rate limits | @RateLimit(max, '1 minute') per IP on a route, on top of the global limit (behind proxies, set TRUST_PROXY=true) |
| Outbound HTTP | safeRequest(url, opts) for user-supplied URLs (webhooks, push endpoints): no private addresses (checked at connection time), no redirects, timeout |
| Enabled manifests | MODULES.manifests(): the core and the modules enabled for the tenant |
| Services of a required module | Declare requires.modules and import the other module's exported Nest module, for example imports: [ContactsModule] from @modulix/module-contacts/server, then inject its services. The kernel installs and enables dependencies first and refuses to disable them while you are enabled. The Leads module is the reference |
| Field errors | throw new UnprocessableEntityException({ message, errors: [{ path, message }] }) renders errors in the problem response, like validation errors |
Web extension points
Slots
| Slot | Where | Props |
|---|---|---|
dashboard.widgets | Dashboard grid (size: sm, md, lg or full) | none |
topbar.actions | Right side of the top bar | none |
sidebar.footer | Above the user menu | none |
profile.sections | Profile page | none |
settings.sections | Reserved for custom settings panels | none |
entity.core.user.badges | Under each user in Administration → Users (keep it compact; one shared query for all rows) | { userId } |
entity.core.user.sections | In the Edit user dialog, below the roles | { userId } |
entity.form | Inside the create/edit form of every declared entity; contributions save through useFormExtension | { entity, entityId } (entityId is null while creating) |
entity.details | On the detail view of every declared entity | { entity, entityId } |
entity.list.header / entity.list.cell | Extra columns of a declared entity's list table: <TableHead> elements, then the matching <TableCell> elements | { entity } / { entity, record } |
entity.list.filters | Extra filters above a declared entity's list; the owner passes value to its list endpoint | { entity, value, onChange } |
entity.<module>.<entity>.<area> | Defined by modules, for example entity.crm.customer.tabs | Module-defined |
Contributions accept order, permission and size. A module defines its own slot by rendering <Slot name="entity.crm.customer.tabs" props={{ customerId }} /> and documenting the props. Modules document their own hooks and slots in their README; for example, the Organization Structure module (modules/org) provides org.unit.* hooks, an org.unit.deleteBlockers filter and the entity.org.unit.sections slot.
Sections in entity.core.user.sections render inside the dialog's form. They save on their own, use type="button" buttons, and a dialog they open must call e.stopPropagation() in its submit handler (React bubbles events through portals).
Other web points
- Routes: files in the package's
modulix.routesdirectory, mounted under/m/<id>in the signed-in app. - Public pages: files in
modulix.publicRoutes, mounted under/p/<id>in a bare layout without sign-in (route ids'/_public/p/<id>/…'). There is no platform context there: call the API with an anonymouscreateApiClient({ baseUrl: '/api/v1', getToken: () => undefined, getTenant: () => undefined }), and announce the organization's language withuseSetPreferences(). The Forms module is the reference. - Commands:
commandsindefineWebModule, shown in the ⌘K palette. - Messages:
messages(defineMessages({ en, fr, de, es })), consumed withuseT(id);useT('common')holds shared words. Labels your manifest declares are translated under conventional keys (module.name,menu.<id>,command.<id>,permission.<key>,settings.<key>[.<value>],entity.<key>[.<field>],action.<key>[.<input>],notification.<key>); see Languages and formats. - Formats:
useFormat()writes dates, times, numbers, money and sizes in the person's language and time zone and the organization's date format;useCurrencyOptions()lists currencies for pickers.
Extensible records
A module opens a record type to other modules by declaring it in its manifest:
entities: [{
key: 'crm.customer',
label: 'Customer',
permissions: { read: 'crm.customer.read', write: 'crm.customer.write' },
// Fields of the snapshot sent with entity hooks (workflow conditions, templates…).
fields: [{ key: 'name', label: 'Name', type: 'string' }, { key: 'tier', label: 'Tier', type: 'enum', options: [...] }],
}]
The core declares core.user, Organization Structure declares org.unit, and Notes declares notes.note. The owner:
- renders the generic
entity.formandentity.detailsslots; - emits
entity.created,entity.updatedandentity.deletedwith a JSON snapshot of the record (entitySnapshot(row)andentityChange(before, after)from@modulix/sdk). See Make your records extensible.
Entity field types: string, text, number, boolean, date, enum, list. Declared entities are listed in /me/context (modules[].entities) and by useEntity(key) in the web SDK, which also says whether the user may read or write such records. Every declared entity also becomes a family of webhook events.
Contributing to entity.form:
function MyFields({ entity, entityId }: EntitySlotProps) {
const mine = entity === 'crm.customer' // or handle every entity, like custom fields
useFormExtension({
validate: () => (!mine || ok ? [] : ['Tier: required']), // runs before the host saves
commit: async (id) => { if (mine) await api.put(`/my-module/customers/${id}`, draft) }, // after it saved
})
if (!mine) return null
…
}
The Custom Fields (modules/custom-fields) and Workflow Automation (modules/automation) modules are built entirely on this contract.
Module actions
Module actions are operations a module lets other modules run on its behalf, for example as workflow steps. Declare them in the manifest (metadata, so they can be listed without running code):
actions: [{
key: 'crm.customer.tag', // prefixed with the module id
label: 'Tag a customer',
permission: 'crm.customer.write', // the caller needs it
inputs: [
{ key: 'customerId', label: 'Customer ID', type: 'text', required: true },
{ key: 'tag', label: 'Tag', type: 'select', options: [{ value: 'vip', label: 'VIP' }] },
],
}]
Then register the handler from a provider:
constructor(@Inject(ACTIONS) private readonly actions: ActionsApi) {}
onModuleInit() {
this.actions.register('crm.customer.tag', async ({ customerId, tag }) => {
await this.tag(String(customerId), String(tag))
return { customerId } // logged by the caller
}, { owner: 'crm' })
}
ACTIONS.list() returns the actions of enabled modules. ACTIONS.run(key, input) checks that the module is enabled (404), the caller's permission (403) and the inputs (422: required, numbers, booleans, select options), then calls the handler in the current request context.
Input types: text, textarea, number, boolean, select. The core provides core.user.setStatus; Notes provides notes.note.create.
Notifications
Declare the kinds of notifications your module sends, so people can choose how to receive each:
notifications: [{ key: 'crm.deal.won', label: 'Deals won', description: 'When a deal you follow is won', defaults: ['inapp', 'email'] }]
Channels are inapp, email and push; mandatory: true keeps a type in the app. Send with the hook, passing the tenant in the context:
await hooks.emit('notification.send', { tenantId, type: 'crm.deal.won', userIds, title, body, link: `/m/crm/deals/${id}` }, { tenantId })
To write it in each recipient's language, add i18n: { title: 'crm:deal.won.title', body?: 'crm:…', params } with keys from your server messages; title and body remain the fallback.
Delivery, preferences and channels belong to the module handling the hook (the Notifications module, modules/notifications). Without it, the hook does nothing.
Stability
@modulix/sdk,@modulix/sdk-serverand@modulix/sdk-webare the public API, versioned with the core (CORE_VERSION).- Declare
requires.coreso incompatible plugins are rejected at boot instead of failing at runtime. - Anything imported from
apps/*or from other packages' internals is not public.