Skip to content

Scenario Files

A scenario is a JSON document that describes one operation: which plugin runs, the execution context it runs in, the data it is allowed to read, and — optionally — the exception it is expected to throw. You hand it to xg run with --scenario, and the FrameworkHost builds the whole IPluginExecutionContext from it.

This page is the canonical reference for that document. A starting template for a real assembly can be produced with xg generate-scenario; everything below is what you edit afterwards.

Four keys are enough to run a plugin:

{
"scenarioName": "Short description of the operation",
"pluginAssemblyName": "MyCompany.Plugins.dll",
"pluginTypeName": "MyCompany.Plugins.Account.ValidateAccount",
"executionContext": {
"messageName": "Create",
"primaryEntityName": "account",
"primaryEntityId": "00000000-0000-0000-0000-000000000001",
"stage": 20,
"mode": 0,
"inputParameters": {
"Target": {
"logicalName": "account",
"id": "00000000-0000-0000-0000-000000000001",
"attributes": { }
}
}
}
}
KeyWhat it is
scenarioNameA human label. It appears in the run output and is never interpreted.
pluginAssemblyNameThe assembly the scenario was written against — documentation for the reader; the assembly actually loaded is the one xg run -a points at.
pluginTypeNameThe fully-qualified plugin type the scenario was written against; the type actually executed is the one xg run -t names.
executionContextEverything the plugin sees through IPluginExecutionContext.

Inside executionContext, three fields carry most of the meaning:

  • messageName — the Dataverse message: Create, Update, Delete, Retrieve, RetrieveMultiple, or a Custom API message (see Custom API scenarios). It is the name your plugin reads to decide whether it should act at all.
  • stage — the pipeline stage: 10 PreValidation, 20 PreOperation, 40 PostOperation. The main operation of a Custom API is stage 30.
  • inputParameters.Target — the record the message carries. For Create and Update it is the entity being written, and its attributes object is what your plugin reads. For Delete it is normally an EntityReference instead.

mode is 0 for synchronous and 1 for asynchronous. depth, userId, initiatingUserId, organizationId, businessUnitId, correlationId, sharedVariables and outputParameters are accepted too, and default to sensible values when omitted.

Inside any attributes object, value typing must be explicit or the framework misreads it. This is the single most common source of scenario errors:

  • Plain values (string, number, bool) are written directly — "name": "Contoso Ltd", "revenue": 1000000, "creditonhold": false.
  • OptionSet / Picklist is { "value": X } — never the bare number. The tag "type": "OptionSetValue" is optional: { "type": "OptionSetValue", "value": X } reads the same.
  • Money is { "type": "Money", "value": X }, where X may be an integer or a decimal. The "type": "Money" tag is what makes it Money: without it, { "value": X } stays an OptionSet. With the current xg release the tag is honoured only in the root-level entities seed, which the CLI hands to the host as written; see the caution below.
  • EntityReference / lookup is { "logicalName": "...", "id": "...", "name": "..." }, where name is optional.
  • GUIDs must be valid hexadecimal. An id containing a non-hex character — an x, a g, a typo — fails to parse inside an EntityReference and takes the whole scenario down with it.
{
"attributes": {
"name": "Contoso Ltd",
"revenue": 1000000,
"statuscode": { "value": 2 },
"primarycontactid": {
"logicalName": "contact",
"id": "00000000-0000-0000-0000-0000000000c1",
"name": "Alex Taylor"
}
}
}

:::caution Where the Money tag is recognised The CLI reads Target, the rest of inputParameters, preEntityImages, postEntityImages and organizationServiceMock and writes them out again before handing them to the host, and the current xg release does not carry the "type": "Money" tag through that step. Use the tag only on records in the root-level entities seed; elsewhere, do not rely on it to produce a Money value. :::

When the plugin reads other records while it runs, those records are described in the scenario — and there are two distinct mechanisms, one per message. Using the wrong one is the second most common mistake, because both “work” in the sense that the JSON parses and neither returns what you expected.

The plugin callsYou describe it inWhere the attributes go
Retrieve (one record, by id)organizationServiceMock.retrieveResponses — or a record of the root-level entities seed with that idNested under an attributes object in retrieveResponses; directly on the entity object in entities
RetrieveMultiple (a query)a root-level entities key, sibling of executionContextDirectly on the entity object

Retrieve — organizationServiceMock.retrieveResponses

Section titled “Retrieve — organizationServiceMock.retrieveResponses”

Each entry pairs the call to match (request) with the record to hand back (response). An entry matches on entityName and id; request.columnSet does not take part in the match, and can be omitted:

{
"organizationServiceMock": {
"retrieveResponses": [
{
"request": {
"entityName": "contact",
"id": "00000000-0000-0000-0000-0000000000c1",
"columnSet": ["fullname", "emailaddress1"]
},
"response": {
"logicalName": "contact",
"id": "00000000-0000-0000-0000-0000000000c1",
"attributes": {
"fullname": "Alex Taylor",
"emailaddress1": "alex.taylor@example.com"
}
}
}
]
}
}

When the scenario answers a Retrieve, the host looks for the record in this order and stops at the first source that has it:

  1. What the run itself did — records the plugin created or updated during this run.
  2. organizationServiceMock.retrieveResponses — the explicit responses above.
  3. The root-level entities seed — see the next section.
  4. The rest of the in-memory store.

So an explicit retrieveResponses entry wins over a seed record with the same entityName and id: declare a record in one place, or expect the response to be the one you see.

Whichever source answers, the record is projected on the ColumnSet the plugin passes — not on the columnSet written in the scenario:

  • columns the plugin did not ask for are not returned;
  • the primary key (<logicalName>id) is always present;
  • new ColumnSet(true) (all columns) returns every attribute the record has;
  • an empty ColumnSet returns only the primary key.

A record that no source holds — or an id that is Guid.Empty — makes Retrieve throw the fault Dataverse throws: a FaultException<OrganizationServiceFault> with error code 0x80040217 (the record does not exist). A plugin that catches that fault sees what it would see in Dataverse.

Writes are visible to later reads within the same run: a Retrieve after the plugin’s Update returns the updated values, and a Retrieve after a Delete of that record throws the same not-found fault, even if retrieveResponses or the seed still describe it.

RetrieveMultiple — the root-level entities key

Section titled “RetrieveMultiple — the root-level entities key”

entities is not inside organizationServiceMock: it sits at the root of the document, next to executionContext. It is grouped by logical name, and each record carries its attributes directly on the object — the opposite of retrieveResponses:

{
"entities": {
"contact": [
{
"id": "00000000-0000-0000-0000-0000000000c1",
"fullname": "Alex Taylor",
"emailaddress1": "alex.taylor@example.com",
"statuscode": { "value": 1 },
"creditlimit": { "type": "Money", "value": 5000.50 },
"parentcustomerid": {
"logicalName": "account",
"id": "00000000-0000-0000-0000-000000000001"
}
}
]
}
}

The CLI does not hand this list back verbatim: the host applies the query itself and returns only the records that match. So every field the query’s filter or order references must be present on the record, or it is filtered out and your plugin sees an empty result it will not explain to you.

The same records also answer a Retrieve by id, after any matching retrieveResponses entry (see How a Retrieve is answered).

A malformed seed fails the run. If the entities value cannot be parsed, or one of its records or attribute values cannot be converted, the plugin is not run against a partial store: the run is reported as failed and xg run exits with code 1, whatever expectedException declares. Fix the seed; a run that silently dropped it would not be testing what you wrote.

A pre-image is supplied through a root-level preEntityImages key, whose name must match the image registered on the step — the same name the code reads:

{
"preEntityImages": {
"PreImage": {
"logicalName": "account",
"id": "00000000-0000-0000-0000-000000000001",
"attributes": {
"name": "Contoso Ltd",
"revenue": 900000
}
}
}
}
var preImage = (Entity)context.PreEntityImages["PreImage"];

postEntityImages works identically. Both keys are also accepted under executionContext; when the same key appears in both places the root-level one wins and the CLI prints a warning saying so, so pick one and keep it there.

A Custom API runs through the same pipeline, but its scenario differs from an entity plugin’s in three ways:

  • Message name: custom_<publisherprefix>_<ApiName> — both the custom_ segment and the publisher prefix are required, because the message name is the API’s unique name.
  • No Target: each declared request parameter is a direct key under inputParameters.
  • Primary entity: primaryEntityName is the empty string "" or omitted — a Custom API is global, not bound to a record. Do not substitute a placeholder such as "none".
{
"scenarioName": "Custom API - validate an invitation code",
"pluginAssemblyName": "MyCompany.Plugins.dll",
"pluginTypeName": "MyCompany.Plugins.Api.ValidateInvitation",
"executionContext": {
"messageName": "custom_mycompany_ValidateInvitation",
"primaryEntityName": "",
"stage": 30,
"mode": 0,
"inputParameters": {
"InvitationCode": "ABC-123",
"RequestedBy": "alex.taylor@example.com"
}
}
}

Backing data works exactly as it does for a plugin: retrieveResponses and entities are read the same way.

A scenario with no expectedException expects the plugin not to throw. Putting the pieces above together:

{
"scenarioName": "Account create — the credit limit is applied",
"pluginAssemblyName": "MyCompany.Plugins.dll",
"pluginTypeName": "MyCompany.Plugins.Account.ApplyCreditLimit",
"executionContext": {
"messageName": "Create",
"primaryEntityName": "account",
"primaryEntityId": "00000000-0000-0000-0000-000000000001",
"stage": 20,
"mode": 0,
"inputParameters": {
"Target": {
"logicalName": "account",
"id": "00000000-0000-0000-0000-000000000001",
"attributes": {
"name": "Contoso Ltd",
"revenue": 1000000,
"primarycontactid": {
"logicalName": "contact",
"id": "00000000-0000-0000-0000-0000000000c1"
}
}
}
}
},
"organizationServiceMock": {
"retrieveResponses": [
{
"request": {
"entityName": "contact",
"id": "00000000-0000-0000-0000-0000000000c1",
"columnSet": ["fullname", "emailaddress1"]
},
"response": {
"logicalName": "contact",
"id": "00000000-0000-0000-0000-0000000000c1",
"attributes": {
"fullname": "Alex Taylor",
"emailaddress1": "alex.taylor@example.com"
}
}
}
]
}
}

The run passes when the plugin completes without throwing, and xg run exits with code 0.

Rejection is behaviour too. Derive the negative case from the scenario above — same plugin, an input the plugin is supposed to refuse — and declare what it must throw:

{
"scenarioName": "Account create — a negative revenue is rejected",
"pluginAssemblyName": "MyCompany.Plugins.dll",
"pluginTypeName": "MyCompany.Plugins.Account.ApplyCreditLimit",
"executionContext": {
"messageName": "Create",
"primaryEntityName": "account",
"primaryEntityId": "00000000-0000-0000-0000-000000000001",
"stage": 20,
"mode": 0,
"inputParameters": {
"Target": {
"logicalName": "account",
"id": "00000000-0000-0000-0000-000000000001",
"attributes": {
"name": "Contoso Ltd",
"revenue": -1
}
}
}
},
"expectedException": {
"typeName": "Microsoft.Xrm.Sdk.InvalidPluginExecutionException",
"messageContains": "Revenue cannot be negative"
}
}

Four rules govern how that declaration is evaluated:

  • typeName is mandatory and fully qualified. It is the exception’s namespace and type name — Microsoft.Xrm.Sdk.InvalidPluginExecutionException, not InvalidPluginExecutionException — and the comparison is exact: no base classes, no case-insensitivity, no partial names. An expectedException whose typeName is missing or empty matches nothing, and the run fails; an unwritten assertion is never a green run.
  • The whole exception chain is examined, from the outermost exception inward through InnerException. When a plugin catches a failure and rethrows it wrapped, the outer type is a legitimate thing to declare — you do not have to dig down to the root cause.
  • messageContains is optional, and is checked on the same link as the type. It is an ordinal substring test on that exception’s message. A link whose type matches but whose message does not is not the answer: the walk continues inward, because the same type can appear twice in one chain and only the inner one may carry the text you named. Omitting messageContains simply states no constraint on the message.
  • A satisfied expectation is a passing run. xg run exits 0 and the run summary reports Execution Succeeded! followed by an Expected Exception Matched: line. That line restates the expectation you declared — the typeName, plus (message contains: …) when messageContains is set — and is the confirmation that the match happened. The summary does not print the thrown exception, its message or its stack trace.

The expectation covers the plugin’s own execution and nothing wider. A failure to load the assembly or to construct the plugin type is the harness being broken, and stays red whatever the scenario declares.

expectedTraceLog is a list of strings the plugin is expected to have written through ITracingService.Trace. It is an assertion, evaluated once the plugin’s Execute has finished:

"expectedTraceLog": [
"Credit limit applied",
"revenue=1000000"
]

These rules govern how the list is evaluated:

  • Each entry is an ordinal, case-sensitive substring. An entry is satisfied when the message of at least one trace emitted by the plugin contains it. "credit limit" does not match a trace that reads Credit limit applied.
  • Only traces with source Plugin count. Lines written by the framework and the host are not searched, and neither is the rendered TraceLog you read with -v or -d: its prefixes and timestamps are presentation, not the corpus. A host line that merely quotes your expected text never satisfies the entry on the plugin’s behalf.
  • Order is not checked, and entries are independent. The entries can appear in any order, and a single trace can satisfy several entries at once.
  • An absent list, or [], asserts nothing. The run is judged on expectedException alone.
  • An empty or whitespace-only entry makes the run red. Every message contains the empty string, so such an entry is not a wildcard: it is reported as invalid, with its position in the list.
  • expectedException and expectedTraceLog combine with AND. Both must hold for the run to pass, and the traces are evaluated also when the plugin throws the expected exception and that exception is captured. When the expected exception never arrives, the run is already red and keeps the expectedException diagnostic.

A missing trace fails the run like any other failed expectation. The summary reports Execution Failed!, then Error Type: XrmGhost.ExpectedTraceLogAssertion — not a CLR type: nothing was thrown, the name tells a missing trace apart from an exception the plugin raised — and an Error Message: listing the entries no plugin trace contained (and the positions of any blank entries). The traces and the rest of the output are kept, so you can compare what the plugin actually traced with what you declared, and xg run exits with code 1.

What happensResult
No expectedException, plugin does not throwPass — exit code 0
No expectedException, plugin throwsFail — exit code 1
expectedException satisfied by some link of the chainPass — exit code 0, summary line Expected Exception Matched:
expectedException declared, plugin throws a different type or a message that does not contain messageContainsFail — exit code 1
expectedException declared, plugin throws nothingFail — exit code 1
expectedException declared with a missing or empty typeNameFail — exit code 1
expectedTraceLog absent or []No trace assertion — the result follows the rows above
Every expectedTraceLog entry found in a Plugin trace, and the exception expectation (if any) holdsPass — exit code 0
An expectedTraceLog entry found in no Plugin traceFail — exit code 1, Error Type: XrmGhost.ExpectedTraceLogAssertion
An expectedTraceLog entry that is empty or whitespace-onlyFail — exit code 1, Error Type: XrmGhost.ExpectedTraceLogAssertion
expectedException satisfied, an expectedTraceLog entry missingFail — exit code 1, Error Type: XrmGhost.ExpectedTraceLogAssertion

Scenarios generated by earlier releases of xg generate-scenario carry a placeholder entry of the form Trace log for {messageName} (expected) — for example "Trace log for Create (expected)". It used to be ignored; now it is an assertion that no plugin traces, so the run turns red with XrmGhost.ExpectedTraceLogAssertion. For each saved scenario, pick one of three fixes:

  • remove the expectedTraceLog key;
  • empty it to "expectedTraceLog": [];
  • replace the placeholder with a substring of a trace the plugin really emits.
  • xg run — execute a scenario, with the full flag reference and exit codes
  • xg generate-scenario — produce a starting template from an assembly and plugin type
  • Using the Skills — how an AI coding agent authors, runs and debugs these files with you