Plug-ins
Existing Dataverse plug-ins (signed .NET Framework assemblies, IPlugin) are registered as in Dataverse, by creating the
rows of pluginassembly (content = base64 of the DLL), plugintype, sdkmessageprocessingstep and
sdkmessageprocessingstepimage via SDK/SOAP (ServiceClient, Plugin Registration Tool) or Web API. Registered steps
take effect immediately, without a restart; a new content replaces the loaded assembly.
| Stage | Name | Mode | Notes |
|---|---|---|---|
| 10 | PreValidation | synchronous | outside the transaction (at the first level) |
| 20 | PreOperation | synchronous | inside the transaction; changes to Target reach the core |
| 30 | Core / main operation | synchronous | the SQL operation (no plugin); for a custom API, its plug-in type, and for a XAML action, its XAML, inside the transaction (not registrable) |
| 40 | PostOperation | synchronous or asynchronous | synchronous inside the transaction; asynchronous queued in asyncoperation |
Messages: Create, Update, Delete, Retrieve, RetrieveMultiple, Assign, SetState (executed as an Update
of statecode/statuscode: Update steps fire, not those registered on SetState), Associate/Disassociate
(1:N, N:N and security relationships, see Security), custom actions (custom sdkmessage row, via SDK/SOAP only) and
custom APIs (see Custom APIs), and AddUserToRecordTeam and RemoveUserFromRecordTeam
(steps without a table filter at stages 10, 20 and 40; AccessTeamId is in the stage 40 output; the team, the membership and
the share are written by the core, so the Create, GrantAccess and Associate steps do not fire for them; a pre-operation
plug-in that changes Record, SystemUserId or TeamTemplateId gets 400; see Security), and RetrieveEntityChanges (a step without a table filter fires for every table and sees it as PrimaryEntityName; the read is at stage 30 like RetrieveMultiple, the result is in OutputParameters["EntityChanges"], and the Web API delta queries run through the same message; see Change tracking). PublishDuplicateRule, UnpublishDuplicateRule, RetrieveDuplicates and BulkDetectDuplicates are core messages with no sdkmessage rows, so a step cannot be registered on any of them (the usual unknown-message 400); a plug-in can call them through its service. The duplicate check of a Create or Update, when the request asks for it, runs after the stage 20 steps on the final values, and a CreateRequest a plug-in executes with SuppressDuplicateDetection = false gets it too (see Duplicate detection). RetrieveExchangeRate (a step without a table filter, PrimaryEntityName transactioncurrency; the read is at stage 30 like RetrieveMultiple, the value is in OutputParameters["ExchangeRate"]; see Currencies). On Create and Update of a table with money, the server sets exchangerate and the _base values at stage 30: pre-operation plug-ins do not see them in Target, post-operation images do, and a _base a plug-in sets is overwritten. For Retrieve and
RetrieveMultiple the result is in OutputParameters["BusinessEntity"] / ["BusinessEntityCollection"], as in Dataverse.
Pre/post images (Create: post only; Delete: pre only) and filtering attributes (on Update only) are supported.
Activities and notes (see Activities and notes):
SendEmailis a message with a filter onemail(stages 10, 20 and 40).PrimaryEntityNameisemailand the primary id isEmailId; pre and post images hold the email (the post image shows the state, status and the sender the send filled in);Subjectis in the output parameters. The send writes the email through the core's Update path, but theUpdateandSetStatesteps ofemaildo not fire for it. The message name isSendEmail; a step registered on a message namedSendfollows the rules for an unknown message.- Party lists are in
Targetas the client sent them (anEntityCollectionofactivitypartyentities per column), and in pre and post images, where each party list of the table is present (empty when it has no rows). Changing a party list inTargetat stage 20 changes what is written. activitypointerand the party rows are written by the core in the activity's transaction, after the activity row and before stage 40: no step fires for these writes, and their message filters (Retrieve, RetrieveMultiple, Associate, Disassociate) allow no write step on them.annotation.documentbodyis inTarget, and in images taken with all columns or naming it (read from the file store each time). The body is written at stage 30, so a body set by a pre-operation plug-in is the one stored.- The refused activity messages (
Book,Rollup,InitializeAnnotationBlocksUpload, …, see What is refused) are refused before any step runs, also when the organization has ansdkmessagerow with that name, and no custom API can take their names.
Registration validation (400 InvalidArgument): content must be a .NET assembly (if missing, name,
version, culture and publickeytoken are taken from its metadata); typename must exist and implement IPlugin or
derive from System.Activities.CodeActivity (a custom workflow activity: isworkflowactivity is then set to true, and a
type sent with isworkflowactivity true that is not one is a 400; see Classic workflows);
stage only 10/20/40, asynchronous only on 40; the filter must belong to the message; the filtered attributes must exist.
On a custom API's message, the API's allowedcustomprocessingsteptype also applies: 0 refuses every step, 1 accepts only
asynchronous stage 40 steps, 2 accepts any.
One assembly per name: a pluginassembly name is unique in the organization, ignoring case, as in Dataverse. A
Create (SDK, SOAP or Web API) with the name of an existing assembly, given or taken from the metadata, and an Update that
renames an assembly to the name of another, fail with 412 0x80040237 (DuplicateRecord): update the existing assembly
instead (xrmd plugin register and the Plugin Registration Tool do). A solution import that carries an assembly under another id than
the organization's assembly of the same name fails with the same error before anything is written, naming the assembly
and both ids (the two are not merged); with the same id it updates the assembly as usual. A database written before
this rule may still hold assemblies sharing a name; when they are the same assembly identity, their builds replace each
other's loaded copy on every execution. The pluginAssemblies check of /admin/doctor (xrmd server doctor) warns
about them, listing their ids; keep one.
Classic workflows (see Classic workflows):
- Real-time workflows and Entity-scope business rules run at stages 20 and 40 in the same list as the synchronous plug-in steps,
ordered by
rank, then plug-in steps before workflows at equal rank, then id. Their steps are requests at depth + 1, so plug-in steps fire on them (the reads of a workflow areRetrieverequests); a workflow that updates the primary record at stage 20 writes intoTargetinstead, as a pre-operation plug-in does. - A background workflow is a system job of
operationtype10, queued at post-operation in the triggering transaction and run by the same worker as asynchronous plug-in steps; it is not retried. - A XAML action (category 3) is a message: steps can be registered on it at stages 20 and 40, and its XAML runs at stage 30.
ExecuteWorkflow(run a workflow on demand) is a core message; it needs Read on the process andprvWorkflowExecution.
Webhooks. A step can also send the execution context to an HTTP receiver instead of calling a plug-in type: its eventhandler is a
serviceendpoint of contract 8 (see Webhooks). Stage, mode, filter, images, rank and asyncautodelete follow the rules above;
a webhook step has no plug-in type and no configuration, and writes no trace rows. As in Dataverse, the Web API navigation property of
sdkmessageprocessingstep.eventhandler is eventhandler_plugintype for a plug-in type (in @odata.bind and $expand) and
eventhandler_serviceendpoint for a webhook. asyncautodelete deletes the succeeded system job of
plug-in and webhook steps alike.
Custom actions and custom APIs. Both are messages that a solution adds, and both are called with an
OrganizationRequest by name.
- A custom action is only an
sdkmessagerow (categoryCustomAction): stage 30 does nothing, the request is not checked against any declared parameter, there is no privilege check, the outputs are whatever the steps set, and it is reachable through the SDK and SOAP only. - A custom API is created over the SDK or the Web API, or imported from the
customapis/folder of a solution: its parameters are validated before stage 10, itsexecuteprivilegenameis demanded, its plug-in type runs at stage 30 as the main operation, its response is completed to the declared properties, and it is also a Web API action or function declared in$metadata. See Custom APIs.
Sandbox and limits
- Each assembly is loaded in a collectible
AssemblyLoadContext;Microsoft.Xrm.SdkandMicrosoft.Crm.Sdk.Proxyare always the host's. No security isolation: plugin code runs in thexrmdprocess. - .NET plug-ins need the Microsoft SDK: the host's
Microsoft.Xrm.SdkandMicrosoft.Crm.Sdk.Proxyare those of the SDK folder that you fill withxrmd sdk install(not shipped with xrmverse), whatever SDK version the plug-in was built against. See Installing the Microsoft SDK for the lookup, the version check and the commands that need it. - Timeout per execution: 2 minutes (
Pipeline:PluginTimeoutSeconds, default 120). - Maximum depth 8: beyond that, error
0x80044182(infinite loop, HTTP 400). InvalidPluginExecutionException→ rollback and HTTP 400 with the plugin's message; any other exception → rollback and HTTP 500 (0x80040224). Via SDK the caller receivesFaultException<OrganizationServiceFault>.- Custom workflow activities (
CodeActivity,isworkflowactivity = true) are registered like plug-in types and run as steps of classic workflows, through CoreWF and the workflow SDK thatxrmd sdk installadds; they follow the same timeout, error mapping, trace and service rules, except thatCreateOrganizationService(null)acts as the workflow's run user. See Classic workflows. - A plugin that cannot be loaded or instantiated (invalid assembly, missing type or dependency, no supported
constructor) → HTTP 500 (
0x80040224) with the messageThe plug-in type '…' could not be loaded: …and a row inplugintracelog. - Nested calls and transaction (as in Dataverse): if a plugin's service call fails inside the
transaction, the transaction is doomed even if the plugin catches the
FaultExceptionand continues; at commit everything is rolled back and the request fails with HTTP 5000x80040224"There is no active transaction. This error is usually caused by custom plug-ins that ignore errors from service calls and continue processing.". Outside the transaction (PreValidation at the first level, asynchronous jobs) a caught error has no consequences. Differences from Dataverse: an error fromWhoAmI,RetrieveEntity,RetrieveAttributeor fromCreateOrganizationServicewith an unknown user does not doom the transaction (they write no data); calls made after the transaction is doomed are still executed and rolled back at commit, instead of failing immediately. CreateOrganizationService(userId)returns a service that acts as that user, with that user's security:Guid.Empty(or the execution's own user id) acts as the user of the execution (context.UserId), andnullacts as the built-in SYSTEM user, as in Dataverse. When the message runs internally (the context reportsUserId=Guid.Empty), "the user of the execution" is SYSTEM. A plug-in never acts as the server's internal caller, so the rules that only internal code may pass (such as writing system-managed teams) refuse a plug-in's service.- On startup xrmd enables
READ_COMMITTED_SNAPSHOTon the organization database if it is off (ALTER DATABASE … WITH ROLLBACK IMMEDIATE): on an existing database the first start interrupts open sessions and requires the ALTER DATABASE permission for the xrmd login. WhoAmI,RetrieveEntityandRetrieveAttributefrom a plugin's service do not go through the pipeline (no steps, as for the frontends);WhoAmIreturns the user the service acts as, its business unit and the organization.- A plugin instance is created on every execution (no instance cache, unlike Dataverse): state in fields does not survive between executions.
- Each nesting level occupies a pool thread (the calling plugin stays blocked waiting for the nested call): a deep chain (up to 8) with many concurrent requests consumes many threads.
- A new
pluginassemblycontentdoes not recheck theplugintyperows already registered: if a type no longer exists, the steps that use it fail at execution (500, see above). RetrieveMultiplevia Web API: plugins seeQueryas a placeholderQueryExpressionthat contains only the entity (not the OData filters); modifying it in place has no effect, only replacingQuerywith another query does.- One copy of each assembly per process for the executions, and a write that changes only the version or other columns
(same
content) keeps it. When an execution first needs a newcontentof an assembly that is already loaded, xrmd releases the old copy. For an ordinary (not early-bound) assembly it then waits a short time for .NET to reclaim the memory of the copy it has just released (not of older copies still in use, which are reclaimed once nothing runs them), and loads the new one. It never waits for an early-bound copy: those stay loaded until restart (two bullets down), so two copies of one early-bound assembly can be in memory side by side, after an update or after the registration check of a new build (next bullet). That matters only to a component that scans the loaded assemblies for proxy types, such as an in-processServiceClient: the SDK keeps a process-wide registry of them and fails with "A proxy type with the name … has been defined by another assembly". - The
typenamecheck of aplugintyperegistration runs inside the registration's transaction, which may still roll back, so it never releases the copy that the executions run. With the samecontentit uses that copy. With another one, it loads an ordinary assembly into a copy of its own, unloaded as soon as the check is done; an early-bound one is loaded beside the copy in use, and the first execution of thatcontent, once the registration has committed, runs it instead of loading another. If the registration rolls back, that early-bound copy stays in memory until restart. - Early-bound assemblies (
[assembly: ProxyTypesAssembly], classes generated by CrmSvcUtil/pac modelbuilder): they work, but the SDK keeps every assembly of this kind alive for the whole life of the process, so its old copy is never released. After an update, both copies stay in memory until xrmd restarts (memory grows with every update). The newcontentruns at once, in synchronous and asynchronous steps alike. Only a DataverseServiceClientrunning inside the same process, whose first proxy-type scan comes after the update, would hit the error above. xrmd hosts noServiceClient, so this only matters to in-process test harnesses: they should connect their client before updating an early-bound assembly.
Trace: ITracingService writes to plugintracelog according to organization.plugintracelogsetting (0 Off,
1 Exception — default, 2 All); messageblock is truncated to 10,240 characters and rows survive rollback.
Async queue: an asynchronous step creates an asyncoperation row (operationtype 1) executed by a background
worker, in creation order. States (statecode/statuscode):
| State | Meaning |
|---|---|
0/0 |
ready (waiting) |
2/20 |
running |
1/10 |
suspended, waiting for the next attempt (postponeuntil) |
3/30 |
completed |
3/31 |
failed (InvalidPluginExecutionException immediately; other errors after Pipeline:AsyncMaxRetries attempts, default 3) |
3/32 |
canceled (step deleted or disabled; for a workflow job, see Classic workflows) |
A job runs as the owner of its asyncoperation row (with their current business unit), never as the users named
in the payload; a nonexistent or disabled owner makes the job fail (3/31). The payload accepts only the types allowed
by the allowlist (see Security).
On startup the jobs left in 2/20 return to 0/0: one xrmd process per database is assumed.
Pipeline:AsyncWorkerEnabled=false disables the worker (jobs are still queued).
Deferred
- Custom actions via Web API (
POST /{action}); custom APIs are on the Web API (see Custom APIs). - Dependency report (
xrmd doctor). - Service Bus endpoints as
eventhandler.
Elastic tables
On an elastic table the stages of a first-level write commit one after the other and a write nested in another message's transaction joins it. Steps cannot be registered on CreateMultiple, UpdateMultiple, UpsertMultiple or DeleteMultiple of an elastic table: register on the single-record message, which fires once per record.
Steps on CreateMultiple and UpdateMultiple
On a standard table you can register steps on CreateMultiple and UpdateMultiple (with a table filter), and they merge
with the Create / Update steps in both directions: a bulk runs the single-record steps once per target, and a plain
Create or Update (also one reached through Upsert, UpsertMultiple or a Web API PATCH) runs the *Multiple steps
with a one-item Targets. A bulk is all-or-nothing in one transaction. Upsert and UpsertMultiple are not events on
standard tables. A step on either that names a standard table is refused, so register on Create or Update. A step on
Upsert with no table filter is still accepted, but it never fires for standard tables.
TargetsreplacesTarget; images come only fromPreEntityImagesCollection/PostEntityImagesCollection, one per target, and an image on an asynchronous or webhook*Multiplestep is refused.- An
UpdateMultiplestep with filtering attributes fires when any target carries one of them. - Only changes to the shared
Entitypersist from a*Multiplestep in a single write; changes to the parameter collections are not carried over. - Asynchronous steps queued inside a bulk carry the bulk context as
ParentContext, so their payloads grow with the bulk. - A bulk message called from plug-in code is refused.
The rules, rulings and unverified points are in Bulk operations.