The registry manifest
Every Callora plugin ships a registry.json at its root, next to (or above) its compiled assembly. It's the plugin's identity card: who the plugin is, what version it is, which tier it deploys into, what capabilities it offers or requires, and which packages it depends on. The host reads it during discovery, install, and activation.
The one thing it is not: a wiring file. Your extensions — controllers, event listeners, services — are attached in code via context.Export(...), not declared here. Think of the manifest as governance metadata, and the entry class as behavior.
What you'll learn
- Every field of
registry.json: which are required, which are optional, and their purpose - How the host parses the manifest (
JsonPluginPackageRegistryReader→PluginRegistryJsonDto) - Why
pluginIdis special — it's also your database schema and asset-root segment - A complete, real example from the Communication plugin
Prerequisites
Read the plugin entry class first — several manifest fields (pluginId, entryTypeName) point directly at your entry class.
Where the manifest lives and how it's found
The host resolves the manifest by walking up from the plugin's assembly directory until it finds a registry.json (JsonPluginPackageRegistryReader.ResolveRegistryPath). In practice that means registry.json sits at the plugin root, and the compiled assembly lands under bin/…/net10.0/ beneath it. You keep one manifest per plugin, at the top.
The fields
The core manifest is parsed into PluginRegistryJsonDto (src/Core/Infrastructure/Plugins/PluginRegistryJsonDto.cs) and validated by JsonPluginPackageRegistryReader. Field names are matched case-insensitively.
| Field | Required | Purpose |
|---|---|---|
contractVersion | Yes | Host↔plugin contract generation. Use "v2" — validated against PluginContractVersionPolicy, where v2 is supported, v1 deprecated (installs with a warning) and v0 removed (rejected). |
schemaVersion | Yes | Version of the registry.json schema itself (e.g. "1.0"). |
name | Yes | Human-readable display name for tooling and the marketplace. |
pluginId | Yes | Stable machine identifier. Must equal the entry class's PluginId. Also derives your DB schema and asset root — see below. |
version | Yes | The plugin's own semantic version (e.g. "0.2.0"). |
assemblyFileName | Yes | File name of the compiled entry assembly (e.g. "Callora.Plugin.Communication.dll"). |
entryTypeName | Yes | Fully-qualified type name of the IHostManagedPlugin implementation. |
tier | Optional | Deployment tier: "system" (foundation) or "application" (default). |
capabilities | Optional | Capability strings this plugin provides (e.g. "communication.voice"). Trimmed and de-duplicated. |
requiresCapabilities | Optional | Capability strings this plugin requires another active plugin to provide. |
dependencies | Optional | Map of package name → version range (e.g. "Callora.Core": ">=0.9.0"). Enforced at install time — see plugin dependencies. |
extensions | Optional | Declared extension-point participations — an array of { extensionPointId, surface }. Both are validated: an unknown point or an unparseable surface makes the manifest invalid. |
permissions | Optional | Permission keys this plugin's routes require — an array of { key, description }. Each key must sit inside the plugin's own namespace and end in a known action; anything else makes the manifest invalid. |
databaseSchema | Optional | Explicit EF schema name for cleanup on uninstall. Read separately (see Fields read outside the core parser). |
sensitiveFields | Optional | Person-related payload field names for webhook data-minimization. Read separately. |
Extension registrations are checked, not assumed
Each entry of extensions names a point and a surface, and both must exist:
"extensions": [
{ "extensionPointId": "admin.api.route", "surface": "admin" }
]An unknown extensionPointId or an unparseable surface makes the manifest invalid (PLUGIN_EXTENSION_POINT_UNKNOWN, PLUGIN_EXTENSION_SURFACE_INVALID). Naming one without the other is refused too (PLUGIN_EXTENSION_POINT_ID_MISSING, PLUGIN_EXTENSION_SURFACE_MISSING). An entry that names neither is skipped — an empty array element is untidy, not a typo.
Why this is checked twice
The runtime path (PluginExtensionSynchronizer) has always refused an unknown point. The manifest path used to skip it silently, so a manifest whose id ends in .mian rather than .main installed, activated, reported healthy and simply never appeared — with nothing in the log to suggest the manifest.
The valid ids are the constants in CalloraExtensionPoints, and analyzer CAL0004 already forbids passing a raw string literal where one belongs in code. The manifest carrying the same value unchecked was the window beside the guarded door.
Declaring the permissions your routes require
CalloraRouteAttribute.Permission lets a route demand a permission key. Until you declare it here, nothing can supply one — the key exists only in your source, and an operator has no way to grant it. A plugin in that state installs, activates, and answers 403 forever.
"permissions": [
{ "key": "communication.trunk.update", "description": "Reconfigure a SIP trunk" },
{ "key": "communication.call.execute", "description": "Place an outbound call" }
]The manifest carries it — not context.Export(...) — although ADR-009 otherwise puts wiring in code. An operator has to see what a plugin will ask for before installing it, and a declaration that only exists once the plugin runs is too late for that decision.
Two rules, both enforced at read time
The key must sit inside your own namespace — it begins with your pluginId and a dot. Declaration is self-service, so without this a plugin could declare user.delete and have an operator grant it in good faith, believing it to be the plugin's own. The separator is part of the check: a plugin called communications cannot declare communication.read.
The key must end in a known action — create, read, update, delete or execute. Keys are granted through role-function-action configuration; one that cannot be expressed there would move the dead end rather than remove it.
The key must be lower case. Authorization compares permission claims exactly, and role configuration emits lower case, so a key with capitals would pass this check and then never match anything — installed, serving 403, looking correct.
Your pluginId must not be a host permission namespace. user, workspace, plugin, role, job and the rest are reserved. Without this the namespace rule defeats itself: a plugin calling itself user would find user.delete genuinely inside its own namespace, and an operator granting the plugin's declared permissions would hand it the host's.
A key breaking either rule makes the whole manifest invalid (PLUGIN_PERMISSION_NOT_DECLARABLE) rather than being skipped. Skipping would put the plugin back in the state this exists to fix: installed, serving 403, with the reason two layers down. Repeating the same key is collapsed, not refused — untidy, not dangerous.
The host builds a role out of them
Declaring keys makes them grantable; it does not put them anywhere. Until now that left the last step to the operator: install a plugin, open role administration, and click its keys together into a role by hand. Miss it, and the plugin is installed, its screens answer 403, and the only account that can use it is the super admin — with nothing anywhere saying why.
So an installation now brings a role with it. For every plugin that has permissions at all, the host creates one role named <pluginId>.admin holding all of them — from the manifest and from IHostAdminApiExtensionContributor.PermissionKeys, because which of the two a plugin uses is its own build decision and not something an operator should have to know.
It is created once and then never touched again. Take a key out, add another, rename the role — that survives every restart, because a tuning decision silently reverted at the next start is worse than a missing permission: the missing one is visible and the reverted one is not. What a plugin update declares on top lands in a log line, not in the role.
The role is found again by the pair (ProvisionedByPluginId, ProvisionedAs) rather than by its name, so renaming it does not produce a second one beside it. A role a human already created under the same name is left alone and reported — it belongs to them, and adopting it would mean treating their keys as the plugin's.
Who the role is for
The provisioned role is carried by platform-scoped operators — the ones who work across workspaces. A workspace administrator needs no role for this: their session picks up the keys of the plugins activated in their own workspace, filtered by activation. See permissions.
The eight required fields
JsonPluginPackageRegistryReader rejects the manifest with a clear error if any of contractVersion, schemaVersion, name, pluginId, version, assemblyFileName, or entryTypeName is missing or blank — and if contractVersion isn't a supported value. A malformed or removed contractVersion fails the plugin outright; a deprecated one activates with a warning.
Declared contracts are what a plugin may publish
contracts names the assemblies that carry your published types:
"contracts": ["Acme.Crm.Contracts.dll"]The host lifts them into the shared load context, so every plugin sees one identity for those types instead of a copy per load context — and that same declaration is what lets a second plugin resolve them from context.Services.
Name them what you like. The gate used to admit assemblies called Callora.Plugin.*.Abstractions, which worked while every plugin came from one house and refused Acme.Crm.Contracts for a reason its author could not have known. It asks the manifest now.
Declare a capability of the plugin whose contracts you consume
Contracts are registered when the declaring plugin activates, and activation runs in capability order. A plugin that consumes another's contracts without requiring one of its capabilities may be activated first — and then the type is not resolvable yet. requiresCapabilities is also what tells an operator, before installing, that one plugin needs the other.
Fields read outside the core parser
databaseSchema and sensitiveFields are not part of PluginRegistryJsonDto. They're read on demand by dedicated services straight from the JSON, so the core parser stays lean and these concerns live with the subsystems that own them:
databaseSchema— read byPluginManifestSchemaReader.TryReadDatabaseSchema(src/Core/Infrastructure/Persistence/). It lets a plugin declare its EF schema explicitly so the host drops exactly that schema on uninstall instead of guessingplugin_<id>. The value is sanitized as a safe DDL identifier.sensitiveFields— read byRegistrySensitiveFieldSyncService.ParseSensitiveFields(src/Core/Infrastructure/Webhooks/). The listed field names are synced into theSensitivePayloadFieldRegistryso outbound webhook payloads mask them — the core never hardcodes a domain field.
Compliance metadata
sensitiveFields and databaseSchema are the manifest's compliance/data-governance surface today. A dedicated Compliance metadata page will cover the full data-handling story.
Status: the Communication manifest below declares
sensitiveFieldsanddatabaseSchema; broader compliance metadata beyond these two fields is not yet part of the manifest.
pluginId is more than an id
pluginId is the one field that leaks into the rest of the platform. Beyond identity, it determines:
- Your database schema.
PluginSchemaName(src/Core/Infrastructure/Persistence/PluginSchemaName.cs) turns yourpluginIdintoplugin_<id>— e.g.communication→plugin_communication. That's the schema your own EF context lives in, isolated from every other plugin. (You can override the exact name with the optionaldatabaseSchemafield.) - Your asset-root segment. The UI asset publisher (
PluginUiAssetPublisher) roots your static assets under a path segment derived frompluginId, keyed by the same id read back fromregistry.json. - Your data partition. The curated
IPluginDataStoreyou receive is plugin-bound bypluginId, so one plugin can never address another's key/value data.
Pick a pluginId once and never change it — renaming it orphans your schema, assets, and stored data.
A complete example
Here is the Communication plugin's manifest, custom/static-plugins/Communication/registry.json, verbatim — including its contractVersion of v1, which is the deprecated tier. It installs, with a warning. A new plugin should declare v2; this one is quoted as it stands, not as a template.
{
"contractVersion": "v1",
"schemaVersion": "1.0",
"name": "Communication",
"pluginId": "communication",
"version": "0.1.0",
"assemblyFileName": "Callora.Plugin.Communication.dll",
"entryTypeName": "Callora.Plugin.Communication.CommunicationPlugin",
"capabilities": [
"communication.foundation"
],
"conditionalCapabilities": [
"communication.voice",
"communication.video",
"communication.webrtc"
],
"sensitiveFields": [
"remoteParty"
],
"dependencies": {
"Callora.Core": ">=0.1.0-local",
"Callora.Plugin.Communication.Abstractions": ">=0.1.0-local"
}
}Reading it top to bottom: this is a plugin named Communication, id communication, version 0.1.0. Its entry class is CommunicationPlugin, in Callora.Plugin.Communication.dll. It provides communication.foundation unconditionally and communication.voice / communication.video / communication.webrtc only while the corresponding runtime dependency is healthy. It marks remoteParty — the telephone number its call events carry — as sensitive, so webhook data-minimization masks it by default, and depends on the core plus its own abstractions package.
Declare the field names you actually emit
The masking registry matches property names in the serialized payload, case-insensitively. CallBusinessEvent.ToEventData() emits remoteParty, so that is the name that must appear here — a plausible-looking phoneNumber would mask nothing. When you change an event's schema, update sensitiveFields in the same commit.
Contrast the consumer side. A dialer plugin requires the capability Communication provides, and provides none of its own:
{
"contractVersion": "v2",
"schemaVersion": "1.0",
"name": "Acme Dialer",
"pluginId": "acme-dialer",
"version": "0.1.0",
"assemblyFileName": "Acme.Dialer.dll",
"entryTypeName": "Acme.Dialer.DialerPlugin",
"capabilities": [],
"requiresCapabilities": [
"communication.voice"
],
"dependencies": {
"Callora.Core": ">=0.9.0",
"Callora.Plugin.Communication.Abstractions": ">=0.9.0"
}
}It provides no capabilities of its own, requires communication.voice, and declares no tier — so it defaults to application. This provider/consumer pairing is exactly how requiresCapabilities and capabilities work together: the host can gate the dialer's activation on Communication being active. See plugin dependencies for the full contract.
The manifest is metadata, not wiring
Worth repeating, because it's the most common misconception: nothing in registry.json attaches an extension. No route is declared here; no event listener is registered here. The manifest tells the host who you are and what you require; your StartAsync tells it what you do, via context.Export(...). Extension wiring is code-first by design — the manifest is the governance layer around it.
Next steps
- Wire the behavior the manifest describes: Dependency injection & exports
- The entry class the manifest points at: The plugin entry class
- Capabilities and dependencies in full: Plugin dependencies
- Field-level reference: Extension manifests reference