---
title: "Plugin authoring"
description: "Package server hooks, browser components, routes, migrations, and diagnostics."
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Plugin authoring

An installable Adonia plugin describes its server, browser, migration, route,
worker, doctor, and compatibility surfaces in one manifest. Ship the server
entry from `.` and, when descriptors use custom component types, the browser
entry from `./client` at the same package version. Use vendor-prefixed
component keys such as `acme/currency`; `adonia/*` is reserved.

## Define a manifest

`definePlugin()` preserves the manifest's literal types. `Panel.plugins()`
performs the runtime checks: `protocolVersion` must equal Adonia's current
protocol, `version` must be valid semver, and `compat.adonia` must include the
installed `@adonia/core` version.

```ts
import { fileURLToPath } from 'node:url'
import {
  definePlugin,
  PROTOCOL_VERSION,
  type AdoniaPluginManifest,
} from '@adonia/core'

export const auditPlugin: AdoniaPluginManifest = definePlugin({
  name: '@acme/adonia-audit',
  version: '1.0.0',
  protocolVersion: PROTOCOL_VERSION,
  compat: { adonia: '>=0.0.1-alpha.0 <1.0.0' },
  client: '@acme/adonia-audit/client',
  migrations: [
fileURLToPath(new URL('./migrations', import.meta.url)),
  ],
})
```

Migration entries are absolute **directories**, not individual files. Keep
the directory in the published package. Source packages commonly point at
`./migrations`; compiled packages then publish the corresponding
`dist/migrations` directory.

Attach manifests in order. Server hooks and runtime hooks preserve that order.

```ts
import { Panel } from '@adonia/core'
import { auditPlugin } from '#adonia/plugins/audit_plugin'

export default Panel.make('admin').plugins([auditPlugin])
```

A protocol mismatch or a failing/invalid compatibility range throws
`E_ADONIA_PLUGIN` while the panel is registered, before routes or workers can
serve traffic.

## Server hook

`server(registry)` runs once for every attached panel during provider boot.
The registry exposes the current `panel` and Adonis `app`, forwards ordered
hooks through `registry.on()`, and adds resources without replacing host
registrations.

```ts
import { definePlugin, PROTOCOL_VERSION } from '@adonia/core'
import AuditResource from '#adonia/resources/audit_resource'

export const auditPlugin = definePlugin({
  name: '@acme/adonia-audit',
  version: '1.0.0',
  protocolVersion: PROTOCOL_VERSION,
  server(registry) {
registry.resource(AuditResource)
registry.on('record:saved', ({ resourceClass, operation }) => {
  console.info(resourceClass.slug, operation)
})
  },
})
```

The protocol-v1 events remain `resource:booted`, `descriptor:compiling`,
`pipeline:building`, `record:saving`, `record:saved`, `record:deleting`,
`record:deleted`, `action:executing`, `action:executed`, `action:failed`,
`nav:building`, and `search:results`. Import `ADONIA_HOOK_EVENTS` instead of
maintaining a second list.

`pipeline.before(stage, fn)`, `after(stage, fn)`, and `replace(stage, fn)` use
the addressable stages `base`, `softDeleteScope`, `search`, `filters`, `sort`,
and `eagerLoad`. A replacement must preserve panel, tenant, parent, lens, and
soft-delete isolation.

Server and route failures abort startup as `E_ADONIA_PLUGIN`. Ordinary
non-record hook failures are logged and isolated. Record hooks propagate
inside the write transaction so the transaction rolls back.

### Legacy server plugins

The pre-manifest `AdoniaPlugin` shape remains source-compatible:
`register(panel, app)` still runs once per attached panel, `boot(app)` still
runs once per plugin name per process, and `cacheKey` still participates in
descriptor cache identity. New packages should use the manifest so tooling,
routes, workers, and compatibility are declared together.

## Scoped routes

Manifest routes are created inside the protected panel route group. The
callback cannot escape the panel's domain, prefix, tenant middleware, or
access gate. Each method takes a relative pattern, a handler, and a
manifest-local name:

```ts
import type { HttpContext } from '@adonisjs/core/http'
import type { PluginRouteContext } from '@adonia/core'

const exportAudit = (ctx: HttpContext) => ctx.response.noContent()

export function routes({ router }: PluginRouteContext): void {
  router.get('audit/export', exportAudit, 'export')
}
```

The example is named
`adonia.<panel>.plugin.@acme/adonia-audit.export`. Leading slashes are
normalized to relative paths. Route callbacks are synchronous because Adonis
commits the route store immediately after provider boot.

## Queue workers

`workers` maps reserved action names to `ActionJobHandler`s. Core dispatches
the matching handler before its normal action worker and supplies the
worker-process container, never an HTTP request:

```ts
import type { ActionJobHandler, ActionResult } from '@adonia/core'

const workers: Record<string, ActionJobHandler> = {
  'acme.audit.export': async (job, { container }) => {
const worker = (await container.make('acme.auditWorker')) as {
  work(input: unknown): Promise<ActionResult>
}
return worker.work(job)
  },
}
```

Worker names must be unique within a panel. The host still starts its queue
consumer and binds application-specific credentials and services; attaching
the manifest removes manual action-name routing.

## Migrations and doctor checks

Devtools consumes manifests structurally, so `@adonia/core` does not import
the doctor runtime. A plugin configure hook can pass manifests to the
installer:

```ts
import { configureAdoniaApp } from '@adonia/devtools/configure'
import { auditPlugin } from '#adonia/plugins/audit_plugin'

const appRoot = new URL('./', import.meta.url).pathname
await configureAdoniaApp(appRoot, { plugins: [auditPlugin] })
```

The installer copies every missing `.ts`, `.js`, `.ts.stub`, or `.js.stub`
migration with an exclusive timestamped filename and registers the
manifest's doctor checks. A process that only needs diagnostics can call
`registerPluginDoctorChecks([auditPlugin])` from
`@adonia/devtools/doctor`; repeated consumption of the same check object is
safe, while a different check with the same id is rejected.

## Client half

The `client` string is package metadata; the browser bundle does not import
it automatically. Register the matching named UI plugin in the application's
component registry:

```tsx
import type { FieldProps, NamedAdoniaUiPlugin } from '@adonia/ui'

function AuditField({ node, control }: FieldProps) {
  return (
<output aria-label={String(node.props.label ?? node.key)}>
  {String(control.value ?? '')}
</output>
  )
}

export const auditUiPlugin: NamedAdoniaUiPlugin = {
  name: '@acme/adonia-audit',
  version: '1.0.0',
  protocolVersion: 1,
  register(registry) {
registry.field('acme/audit', AuditField)
  },
}
```

```ts
import { adonia } from '@adonia/ui'
import { auditUiPlugin } from '#adonia/plugins/audit_ui'

export default adonia({
  registry(registry) {
registry.use(auditUiPlugin)
  },
})
```

A missing client registration does not crash sibling rendering. Development
renders `UnknownComponent`; production renders nothing; both warn.

## Package exports

Expose the manifest and advanced server primitives from `.`, and the named UI
plugin from `./client`. Keep both versions equal. Before publishing, run the
plugin conformance kit from `@adonia/core/testing`; TypeScript alone does not
prove hook ordering, isolation, or transaction behavior.

See the [generated API reference](/reference/api) and the
[reference plugins](/guide/reference-plugins) for complete examples.

Source: https://adonia.pages.dev/guide/plugin-authoring/index.mdx
