Skip to content

CRMWorx walkthrough

This guide builds CRMWorx — an IT-company ticketing platform with SLA — end to end using the crm CLI, demonstrating every command group. Each step shows the real command and its captured output from a live run against a Dynamics 365 CE on-premises v9.1 server (credentials redacted).

The build order is: option sets → entities → attributes → relationships → seed data → read/verify → package → views → forms → charts → dashboard → BPF → sitemap & app → launch → (optional teardown).

Prerequisites

  • A reachable D365 CE on-prem server and NTLM credentials (via .env: CRM_BASE_URL, CRM_USERNAME, CRM_PASSWORD, CRM_AUTH=ntlm, CRM_API_VERSION). The password is read from D365_PASSWORD, with CRM_PASSWORD accepted as an alias — both names work.
  • A CRMWorx unmanaged solution and a publisher with prefix cwx. Create both from the CLI — no web UI (#34). --if-exists skip makes re-runs a no-op:
crm --json solution create-publisher --name crmworx --display CRMWorx --prefix cwx \
  --option-value-prefix 30000 --if-exists skip
crm --json solution create --name CRMWorx --publisher crmworx --if-exists skip

With a named profile active, these auto-wire publisher_prefix=cwx and default_solution=CRMWorx into it, so the metadata commands below target the cwx publisher and CRMWorx solution by default (pass --no-set-default to opt out).

1. Pre-flight & connection

Confirm reachability and identity. A non-zero exit (e.g. 401) means the DOMAIN\username credentials are wrong — fix them before continuing.

crm --json connection whoami
{
  "ok": true,
  "data": {
    "@odata.context": ".../api/data/v9.1/$metadata#Microsoft.Dynamics.CRM.WhoAmIResponse",
    "BusinessUnitId": "078a426a-8339-f111-b65d-00155d467b90",
    "UserId": "f890426a-8339-f111-b65d-00155d467b90",
    "OrganizationId": "b948cd5f-8339-f111-b65d-00155d467b90"
  }
}

Save a targeting profile so every mutating metadata command lands in the CRMWorx solution and uses the cwx schema-name prefix by default. connect saves the profile and validates the credentials with a WhoAmI call (the password is read from the D365_PASSWORD environment variable, never passed on the command line):

crm --json connection connect \
  --url "$CRM_BASE_URL" --username "$CRM_USERNAME" \
  --api-version v9.1 \
  --default-solution CRMWorx --publisher-prefix cwx \
  --profile-name crmworx
{
  "ok": true,
  "data": {
    "ok": true,
    "user_id": "f890426a-8339-f111-b65d-00155d467b90",
    "api_base": "http://<server>/<org>/api/data/v9.1/",
    "api_version": "v9.1"
  },
  "meta": { "profile": "crmworx" }
}

Verify the active profile resolves the solution and prefix:

crm --json connection profiles
crm --json connection status
{
  "ok": true,
  "meta": {
    "profiles": [
      { "name": "crmworx", "default_solution": "CRMWorx", "publisher_prefix": "cwx" }
    ]
  }
}

connection status confirms active_profile: crmworx with default_solution: CRMWorx and publisher_prefix: cwx. From here, metadata create-* commands target CRMWorx automatically (override per-command with --solution, or hard-fail when none resolves with --require-solution / CRM_REQUIRE_SOLUTION).

2. Metadata build (option sets → entities → attributes → relationships)

2.1 Global option sets

CRMWorx uses four global (reusable) option sets. Preview the request first with --dry-run to confirm the shape and that the CRMWorx solution is targeted — note the MSCRM.SolutionUniqueName: CRMWorx header and the GlobalOptionSetDefinitions endpoint:

crm --json --dry-run metadata create-optionset \
  --name cwx_priority --display "CRMWorx Priority" \
  --option 1:Low --option 2:Normal --option 3:High --option 4:Critical \
  --if-exists skip
{
  "ok": true,
  "data": {
    "_dry_run": true,
    "method": "POST",
    "url": ".../api/data/v9.1/GlobalOptionSetDefinitions",
    "headers": { "MSCRM.SolutionUniqueName": "CRMWorx" },
    "body": {
      "@odata.type": "Microsoft.Dynamics.CRM.OptionSetMetadata",
      "Name": "cwx_priority", "IsGlobal": true, "OptionSetType": "Picklist",
      "Options": [ { "Value": 1, "Label": { "...": "Low" } }, "...etc" ]
    }
  }
}

Now create all four. --if-exists skip makes each create idempotent (proven in §5):

crm --json metadata create-optionset --name cwx_priority --display "CRMWorx Priority" \
  --option 1:Low --option 2:Normal --option 3:High --option 4:Critical --if-exists skip
crm --json metadata create-optionset --name cwx_severity --display "CRMWorx Severity" \
  --option 1:Minor --option 2:Major --option 3:Critical --if-exists skip
crm --json metadata create-optionset --name cwx_ticketcategory --display "CRMWorx Category" \
  --option 1:Hardware --option 2:Software --option 3:Network --option 4:Access --if-exists skip
crm --json metadata create-optionset --name cwx_slatier --display "CRMWorx SLA Tier" \
  --option 1:Bronze --option 2:Silver --option 3:Gold --if-exists skip

Each returns created: true with the new metadata id, the target solution, and published: true:

{
  "ok": true,
  "data": {
    "created": true,
    "name": "cwx_priority",
    "metadata_id_url": ".../GlobalOptionSetDefinitions(a2ca6b21-...)",
    "solution": "CRMWorx",
    "published": true
  }
}

Verify all four landed:

crm --json metadata list-optionsets --custom-only | grep -oE 'cwx_[a-z]+' | sort -u
cwx_priority
cwx_severity
cwx_slatier
cwx_ticketcategory

2.2 Custom entities

Two tables: SLA Policy (organization-owned reference data) and Support Ticket (user-owned, note- and activity-enabled). The schema name is given in PascalCase with the cwx_ prefix; the server derives the lowercase logical name and the entity-set (plural) name used by the Web API:

crm --json metadata create-entity \
  --schema-name cwx_SLA --display "SLA Policy" --display-collection "SLA Policies" \
  --primary-attr cwx_Name --primary-label "Policy Name" \
  --ownership OrganizationOwned --has-notes --if-exists skip

crm --json metadata create-entity \
  --schema-name cwx_Ticket --display "Support Ticket" --display-collection "Support Tickets" \
  --primary-attr cwx_Name --primary-label "Ticket Title" \
  --ownership UserOwned --has-notes --has-activities --if-exists skip
{
  "ok": true,
  "data": {
    "created": true,
    "schema_name": "cwx_SLA",
    "logical_name": "cwx_sla",
    "entity_set_name": "cwx_slas",
    "primary_attribute": "cwx_name",
    "solution": "CRMWorx",
    "published": true
  }
}

Note the entity_set_name in each response — cwx_slas and cwx_tickets. These plural names are what you pass to entity/query commands later (§3–§4), not the logical name. Verify both tables exist:

crm --json metadata entities --custom-only | grep -oE 'cwx_(sla|ticket)\b' | sort -u
cwx_sla
cwx_ticket

2.3 Attributes (all kinds)

Add columns to both tables. The cwx_sla table gets two integer bounds, a global picklist, and a boolean:

crm --json metadata add-attribute cwx_sla --kind integer \
  --schema-name cwx_ResponseHours --display "Response Hours" --min 0 --max 720 --if-exists skip
crm --json metadata add-attribute cwx_sla --kind integer \
  --schema-name cwx_ResolutionHours --display "Resolution Hours" --min 0 --max 2160 --if-exists skip
crm --json metadata add-attribute cwx_sla --kind picklist \
  --schema-name cwx_Tier --display "Tier" --optionset-name cwx_slatier --if-exists skip
crm --json metadata add-attribute cwx_sla --kind boolean \
  --schema-name cwx_Active --display "Active" --true-label Yes --false-label No --if-exists skip

The cwx_ticket table gets a memo, three global picklists, and three datetimes:

crm --json metadata add-attribute cwx_ticket --kind memo \
  --schema-name cwx_Description --display "Description" --max-length 4000 --if-exists skip
crm --json metadata add-attribute cwx_ticket --kind picklist \
  --schema-name cwx_Priority --display "Priority" --optionset-name cwx_priority --if-exists skip
crm --json metadata add-attribute cwx_ticket --kind picklist \
  --schema-name cwx_Severity --display "Severity" --optionset-name cwx_severity --if-exists skip
crm --json metadata add-attribute cwx_ticket --kind picklist \
  --schema-name cwx_Category --display "Category" --optionset-name cwx_ticketcategory --if-exists skip
crm --json metadata add-attribute cwx_ticket --kind datetime \
  --schema-name cwx_OpenedOn --display "Opened On" --if-exists skip
crm --json metadata add-attribute cwx_ticket --kind datetime \
  --schema-name cwx_ResolvedOn --display "Resolved On" --if-exists skip
crm --json metadata add-attribute cwx_ticket --kind datetime \
  --schema-name cwx_DueBy --display "Due By" --if-exists skip

Each returns the created column with its resolved logical name and type:

{
  "ok": true,
  "data": {
    "created": true,
    "entity": "cwx_ticket",
    "schema_name": "cwx_Priority",
    "logical_name": "cwx_priority",
    "attribute_type": "Picklist",
    "solution": "CRMWorx",
    "published": true
  }
}

A picklist that references a global option set binds to it through the GlobalOptionSet navigation property. Confirm the binding by expanding it from the metadata endpoint:

attr: cwx_priority -> GlobalOptionSet.Name: cwx_priority

Two CLI defects fixed during this step

Building these columns against the live 9.1 server surfaced two add-attribute bugs, fixed inline (commit cf7d41d):

  • Integer bounds were serialized as floats (--min 00.0), which the server rejected for an Edm.Int32 column. Integer/bigint bounds are now coerced to integers.
  • Global picklists were sent as an inline option set with IsGlobal=true, which the server rejects on attribute create. They now bind via GlobalOptionSet@odata.bind; on-prem 9.1 requires the option set's MetadataId GUID for the bind (the Name alternate key is rejected), so the CLI resolves Name → MetadataId first.

2.4 Relationships (1:N + 1:N + N:N)

Three relationships wire the model together: SLA→Ticket and Account→Ticket (each a 1:N that creates a lookup column on the ticket), and Ticket↔SystemUser (an N:N "watchers" link with an intersect table).

# SLA -> Ticket (creates the cwx_sla lookup on the ticket)
crm --json metadata create-one-to-many \
  --schema-name cwx_sla_cwx_ticket \
  --referenced-entity cwx_sla --referencing-entity cwx_ticket \
  --lookup-schema cwx_SLA --lookup-display "SLA Policy" --if-exists skip

# Account -> Ticket (creates the cwx_customerid lookup on the ticket)
crm --json metadata create-one-to-many \
  --schema-name cwx_account_cwx_ticket \
  --referenced-entity account --referencing-entity cwx_ticket \
  --lookup-schema cwx_CustomerId --lookup-display "Customer" --if-exists skip

# Ticket <-> SystemUser (N:N watchers)
crm --json metadata create-many-to-many \
  --schema-name cwx_ticket_systemuser \
  --entity1 cwx_ticket --entity2 systemuser \
  --intersect-entity cwx_ticket_systemuser --if-exists skip

A 1:N response reports the created relationship and the lookup column the server generated on the referencing entity:

{
  "ok": true,
  "data": {
    "created": true,
    "kind": "OneToMany",
    "schema_name": "cwx_sla_cwx_ticket",
    "referencing_attribute": "cwx_sla",
    "solution": "CRMWorx"
  }
}

Verify all three (note metadata relationships lists an entity's one-to-many, many-to-one, and many-to-many links) and publish:

crm --json metadata relationships cwx_ticket \
  | grep -oE 'cwx_(sla_cwx_ticket|account_cwx_ticket|ticket_systemuser)' | sort -u
crm --json solution publish-all
cwx_account_cwx_ticket
cwx_sla_cwx_ticket
cwx_ticket_systemuser

Three more CLI defects fixed during this step

Creating relationships against the live server exposed (commits c62d57a, 980ab95):

  • Wrong endpointcreate-one-to-many/create-many-to-many POSTed to CreateOneToManyRequest/CreateManyToManyRequest, which are SDK message names, not Web API segments ("Resource not found for the segment ..."). They now POST to the RelationshipDefinitions entity set with an @odata.type discriminator (the 1:N lookup is a Lookup deep insert).
  • Invalid default menu — the 1:N associated-menu defaulted to UseLabel with no label, which the server rejects. Default is now UseCollectionName.
  • Incomplete read-back / listingReferencingAttribute came back null (the read-back didn't cast to the relationship subtype), and metadata relationships omitted the many-to-one side. Both fixed.

3. Seed data

Records go to the entity-set (plural) namecwx_slas, cwx_tickets, accounts — not the logical name. First two SLA policies; each create returns the full row, including its cwx_slaid GUID:

crm --json entity create cwx_slas --data '{"cwx_name":"Gold 4h/24h","cwx_responsehours":4,"cwx_resolutionhours":24,"cwx_tier":3,"cwx_active":true}'
crm --json entity create cwx_slas --data '{"cwx_name":"Bronze 24h/120h","cwx_responsehours":24,"cwx_resolutionhours":120,"cwx_tier":1,"cwx_active":true}'

A customer account:

crm --json entity create accounts --data '{"name":"Contoso IT Dept"}'

Now a ticket that binds both lookups with @odata.bind, using the SLA and account GUIDs from above. The bind target is the navigation property name, which is the PascalCase lookup schema name — cwx_SLA and cwx_CustomerId, not the lowercase logical names. If you guess wrong, read the real name from the relationship:

crm --json query odata \
  "RelationshipDefinitions(SchemaName='cwx_account_cwx_ticket')/Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata" \
  --select ReferencingAttribute,ReferencingEntityNavigationPropertyName
# -> ReferencingAttribute: cwx_customerid, NavigationPropertyName: cwx_CustomerId
crm --json entity create cwx_tickets --data '{
  "cwx_name":"Laptop won'\''t boot",
  "cwx_description":"Dell 5420 no POST after update",
  "cwx_priority":3, "cwx_severity":2, "cwx_category":1,
  "cwx_CustomerId@odata.bind":"/accounts(c2c130c3-c05d-f111-b65d-00155d467b90)",
  "cwx_SLA@odata.bind":"/cwx_slas(00d955b7-c05d-f111-b65d-00155d467b90)"
}'

The response echoes the bound foreign keys:

{ "ok": true, "data": {
    "cwx_ticketid": "a41cfedb-...",
    "cwx_name": "Laptop won't boot",
    "_cwx_sla_value": "00d955b7-...",
    "_cwx_customerid_value": "c2c130c3-..."
} }

Modify a record with update (PATCH) and upsert (PATCH with create-if-missing). With no alternate key configured on the ticket, both target the record by id:

crm --json entity update cwx_tickets a41cfedb-c05d-f111-b65d-00155d467b90 \
  --data '{"cwx_resolvedon":"2026-06-01T12:00:00Z"}'
crm --json entity upsert cwx_tickets c8c8f8e4-c05d-f111-b65d-00155d467b90 \
  --data '{"cwx_resolvedon":"2026-06-01T15:30:00Z"}'

Both return {"ok": true}.

4. Read & verify

Four read paths. An OData query with a filter and projection:

crm --json query odata cwx_tickets \
  --filter "cwx_priority eq 3" --select cwx_name,cwx_severity --top 10
{ "ok": true, "data": { "value": [
  { "cwx_name": "Laptop won't boot", "cwx_severity": 2 }
] } }

A FetchXML query (server-side XML query language) returning all tickets ordered by name:

crm --json query fetchxml cwx_tickets --xml '
<fetch top="20">
  <entity name="cwx_ticket">
    <attribute name="cwx_name"/>
    <attribute name="cwx_priority"/>
    <order attribute="cwx_name"/>
  </entity>
</fetch>'

Returns both tickets (Laptop won't boot pri=3, VPN drops every 10 min pri=2).

A bulk CSV export to a file (the committed sample is docs/artifacts/crmworx-tickets.csv):

crm data export cwx_tickets -o docs/artifacts/crmworx-tickets.csv \
  --select cwx_name,cwx_priority,cwx_severity,cwx_category
output: docs/artifacts/crmworx-tickets.csv
format: csv
count: 2

And an action/function call — RetrieveCurrentOrganization:

crm --json action function RetrieveCurrentOrganization --params '{"AccessType":"Default"}'
{ "ok": true, "data": { "Detail": {
  "FriendlyName": "Contoso",
  "OrganizationVersion": "9.1.44.15"
} } }

5. Package the solution

Idempotency

Every metadata create uses --if-exists skip, so re-running the whole build is a safe no-op. Re-running any create reports a skip rather than erroring or duplicating:

crm --json metadata create-optionset --name cwx_priority --display "CRMWorx Priority" \
  --option 1:Low --option 2:Normal --option 3:High --option 4:Critical --if-exists skip
crm --json metadata create-entity --schema-name cwx_SLA --display "SLA Policy" ... --if-exists skip
{ "ok": true, "data": { "skipped": true, "exists": true } }

Export

Export the unmanaged solution to a zip (committed sample: docs/artifacts/crmworx.zip):

crm solution export CRMWorx -o docs/artifacts/crmworx.zip
output: docs/artifacts/crmworx.zip
bytes: 83633
managed: False
solution: CRMWorx
action: ExportSolution

Verify the export contains the model. solution components returns component type + objectid (GUID) rows — componenttype 9 is an option set, 1 is an entity. CRMWorx contains all four option sets and both custom entities (plus the N:N intersect entity):

crm --json solution components CRMWorx
# -> 8 components: 4 option sets (type 9) + 4 entities (type 1)
#    option sets  = cwx_priority, cwx_severity, cwx_ticketcategory, cwx_slatier
#    entities     = cwx_sla, cwx_ticket, cwx_ticket_systemuser (intersect), +1

Solution-export defect fixed during this step

solution export only called ExportSolutionAsync, which is not enabled on this on-prem 9.1 org ("ExportSolutionAsync is not enabled for this org"). It now falls back to the synchronous ExportSolution action (which returns the zip bytes inline) when the async action is unavailable (commit a87b8f2). The response action field shows which path ran.

6. Views

A model-driven view is a savedquery row: returnedtypecode (the entity logical name), querytype (0 = public view), and two XML columns — layoutxml (grid columns + widths) and fetchxml (the query: columns, sort, filter). The grid's object="<n>" attribute is the entity ObjectTypeCode, an org-specific integer — capture it live rather than guessing (custom-entity OTCs on on-prem are typically ≥ 10000):

crm --json metadata entity cwx_ticket   # -> data.ObjectTypeCode = 10127
crm --json metadata entity cwx_sla       # -> data.ObjectTypeCode = 10126

Column logical names ≠ option-set names

The picklist/lookup columns are cwx_priority, cwx_severity, cwx_category, cwx_tier, and the SLA lookup cwx_slanot the global option-set names (cwx_ticketcategory, cwx_slatier). Confirm column logical names with crm --json metadata attributes <entity> before referencing them in FetchXml; the option set a picklist binds to can have a different name from the column.

Both XML blocks contain double quotes, so pass the record as a JSON file (--data-file) rather than fighting shell quoting on --data. The LayoutXml + FetchXml the server accepted for Active Tickets (newline-formatted here; stored single-line in the JSON):

<grid name="resultset" object="10127" jump="cwx_name" select="1" icon="1" preview="1">
  <row name="result" id="cwx_ticketid">
    <cell name="cwx_name" width="220" />
    <cell name="cwx_priority" width="120" />
    <cell name="cwx_severity" width="120" />
    <cell name="cwx_customerid" width="180" />
    <cell name="createdon" width="140" />
    <cell name="statuscode" width="120" />
  </row>
</grid>
<fetch version="1.0" output-format="xml-platform" mapping="logical">
  <entity name="cwx_ticket">
    <attribute name="cwx_ticketid" />
    <attribute name="cwx_name" />
    <attribute name="cwx_priority" />
    <attribute name="cwx_severity" />
    <attribute name="cwx_customerid" />
    <attribute name="createdon" />
    <attribute name="statuscode" />
    <order attribute="cwx_name" descending="false" />
    <filter type="and">
      <condition attribute="statecode" operator="eq" value="0" />
    </filter>
  </entity>
</fetch>

/tmp/cwx_view_active_tickets.json wraps those two blocks as escaped single-line strings:

{
  "name": "Active Tickets",
  "returnedtypecode": "cwx_ticket",
  "querytype": 0,
  "isdefault": false,
  "layoutxml": "<grid name=\"resultset\" object=\"10127\" ...></grid>",
  "fetchxml":  "<fetch ...><entity name=\"cwx_ticket\">...</entity></fetch>"
}

Preview the POST, guard against a duplicate (savedquery has no alternate key, so filter by name + entity), then create:

crm --json --dry-run entity create savedqueries --data-file /tmp/cwx_view_active_tickets.json
crm --json query odata savedqueries \
  --filter "name eq 'Active Tickets' and returnedtypecode eq 'cwx_ticket'" --select name,savedqueryid
crm --json entity create savedqueries --data-file /tmp/cwx_view_active_tickets.json
{ "ok": true, "data": { "savedqueryid": "72313649-6f5e-f111-b65d-00155d467b90", "...": "..." } }

Two more views follow the same guard → create flow — Tickets by Priority (cwx_ticket; LayoutXml leads with cwx_priority, FetchXml ordered by cwx_priority) and Active SLAs ("returnedtypecode": "cwx_sla", object="10126"; cells cwx_name, cwx_tier, cwx_responsehours, cwx_resolutionhours):

Active Tickets       72313649-6f5e-f111-b65d-00155d467b90
Tickets by Priority  74313649-6f5e-f111-b65d-00155d467b90
Active SLAs          76313649-6f5e-f111-b65d-00155d467b90

Publish so the views surface in the app, then read them back (the build's three plus the two auto-generated defaults D365 created with the entity):

crm --json solution publish-all
crm --json query odata savedqueries \
  --filter "returnedtypecode eq 'cwx_ticket' and querytype eq 0" --select name,savedqueryid
Active Tickets             72313649-6f5e-f111-b65d-00155d467b90
Tickets by Priority        74313649-6f5e-f111-b65d-00155d467b90
Active Support Tickets     055e2e32-dc59-4190-a9ff-0a8c1d88cf7f   (auto-generated default)
Inactive Support Tickets   9665360a-d424-4fd5-8a16-7a979e9988a4   (auto-generated default)

7. Forms

Forms are also systemform rows: objecttypecode (entity logical name), type (2 = main, 7 = quickCreate), and a formxml layout. Authoring main FormXml from scratch has a high 9.1 rejection rate, so clone-and-augment the auto-generated main form instead — fetch it, inject controls for the custom columns, PATCH it back.

Main form (clone-and-augment)

Fetch the auto-generated "Information" form and note its formid + FormXml length:

crm --json query odata systemforms \
  --filter "objecttypecode eq 'cwx_ticket' and type eq 2" --select name,formid,formxml
# -> Information | 8f0db50d-b90c-4b4d-9ecb-f16f9e7db491 | formxml 1625 chars

The auto form has one tab/two columns: cwx_name + ownerid on the left, the notes control on the right. Keep all of it intact and add one <row> per missing custom column to the first section, copying the existing <cell>/<control> shape and swapping datafieldname + classid. The control classid is per AttributeType (read it from crm --json metadata attributes cwx_ticket):

AttributeType Control classid
String {4273EDBD-AC1D-40d3-9FB2-095C621B552D}
Picklist {3EF39988-22BB-4f0b-BBBE-64B5A3748AEE}
Lookup {270BD3DB-D9AF-4782-9025-509E298DEC0A}

Each added row (every <cell> needs a unique GUID id):

<row>
  <cell id="{c0ffee00-…}">
    <labels><label description="Priority" languagecode="1033" /></labels>
    <control id="cwx_priority" classid="{3EF39988-22BB-4f0b-BBBE-64B5A3748AEE}"
             datafieldname="cwx_priority" />
  </cell>
</row>

Five rows added — cwx_priority, cwx_severity, cwx_category (Picklist), cwx_sla, cwx_customerid (Lookup) — taking the FormXml 1625 → 2834 chars. Preview, PATCH, publish:

crm --json --dry-run entity update systemforms 8f0db50d-… --data-file /tmp/cwx_ticket_mainform_patch.json
crm --json entity update systemforms 8f0db50d-… --data-file /tmp/cwx_ticket_mainform_patch.json
crm --json solution publish-all

Read back proves the five columns are now on the form:

crm --json query odata systemforms --filter "formid eq 8f0db50d-…" --select formxml
# cwx_priority True | cwx_severity True | cwx_category True | cwx_sla True | cwx_customerid True

Quick-create form (type=7)

A quick-create form is authored from scratch (it's small): a single 100%-width column with cwx_name, cwx_priority, cwx_customerid. Guard, create, publish, read back:

crm --json query odata systemforms --filter "objecttypecode eq 'cwx_ticket' and type eq 7" --select name,formid
crm --json entity create systemforms --data-file /tmp/cwx_ticket_qc_form.json
crm --json solution publish-all
{ "ok": true, "data": { "formid": "94d4181e-705e-f111-b65d-00155d467b90", "...": "..." } }

Quick-create via the Web API works on 9.1

On-prem 9.1's Web API accepted the systemforms POST with type=7 on the first attempt — no manual-portal fallback was needed. Read-back confirms cwx_priority and cwx_customerid are present in the stored FormXml.

8. Charts

A chart is a savedqueryvisualization row with two XML columns: datadescription (an aggregate FetchXML wrapped in <datadefinition> — the what) and presentationdescription (a <Chart> serialization of the .NET Chart control — the how). The presentationdescription has no schema; copy a canonical single-series shape from MS Learn (Sample charts, op-9-1) and only swap the ChartType.

Tickets by Priority — group cwx_ticket by cwx_priority, count cwx_ticketid:

<datadefinition>
  <fetchcollection>
    <fetch mapping="logical" aggregate="true">
      <entity name="cwx_ticket">
        <attribute groupby="true" alias="groupby_column" name="cwx_priority" />
        <attribute alias="aggregate_column" name="cwx_ticketid" aggregate="count" />
      </entity>
    </fetch>
  </fetchcollection>
  <categorycollection>
    <category>
      <measurecollection><measure alias="aggregate_column" /></measurecollection>
    </category>
  </categorycollection>
</datadefinition>

The presentationdescription is the canonical ChartType="Column" block from the MS Learn samples verbatim (series style + <ChartAreas> axes + <Titles>). Guard, preview, create, publish, read back:

crm --json query odata savedqueryvisualizations \
  --filter "name eq 'Tickets by Priority' and primaryentitytypecode eq 'cwx_ticket'" --select name,savedqueryvisualizationid
crm --json --dry-run entity create savedqueryvisualizations --data-file /tmp/cwx_chart_priority.json
crm --json entity create savedqueryvisualizations --data-file /tmp/cwx_chart_priority.json
crm --json solution publish-all
crm --json query odata savedqueryvisualizations --filter "primaryentitytypecode eq 'cwx_ticket'" --select name,savedqueryvisualizationid
Tickets by Priority | b7510172-705e-f111-b65d-00155d467b90

The server accepted the chart on the first attempt. Keep that savedqueryvisualizationid — the dashboard in §9 binds it.

9. Dashboard

A dashboard is a systemform with type = 0. Its formxml lays out a grid of components; every chart/grid component uses the same control classid {E7A81278-8635-4d9e-8D4D-59480B391C5B} and is configured purely through <parameters>. The load-bearing parameters are <ChartGridMode> (Chart vs Grid), <VisualizationId> (the §8 chart GUID), and <ViewId> (the §6 view GUID that supplies the data).

CRMWorx SLA Overview — a single tab, one section, two side-by-side cells: the "Tickets by Priority" chart and a list, both reading the "Active Tickets" view:

<cell colspan="1" rowspan="12" showlabel="false" id="{…}" auto="false">
  <control id="Chart1" classid="{E7A81278-8635-4d9e-8D4D-59480B391C5B}">
    <parameters>
      <TargetEntityType>cwx_ticket</TargetEntityType>
      <ChartGridMode>Chart</ChartGridMode>
      <ViewId>{72313649-6f5e-f111-b65d-00155d467b90}</ViewId>          <!-- Active Tickets -->
      <IsUserView>false</IsUserView>
      <VisualizationId>{b7510172-705e-f111-b65d-00155d467b90}</VisualizationId>  <!-- chart -->
      <IsUserChart>false</IsUserChart>
      <EnableChartPicker>false</EnableChartPicker>
    </parameters>
  </control>
</cell>

Guard, create, publish, read back:

crm --json query odata systemforms --filter "name eq 'CRMWorx SLA Overview' and type eq 0" --select name,formid
crm --json entity create systemforms --data-file /tmp/cwx_dashboard.json
crm --json solution publish-all
crm --json query odata systemforms --filter "type eq 0 and name eq 'CRMWorx SLA Overview'" --select name,formid,formxml
CRMWorx SLA Overview | e29005b6-705e-f111-b65d-00155d467b90
binds chart b7510172: True | binds view 72313649: True

Created on the first attempt. Keep the dashboard formid — the model-driven app in §11 adds it as a component.

10. Business process flow (documented manual fallback)

A BPF is a workflows row with category = 4 (BusinessProcessFlow), type = 1 (Definition), primaryentity = cwx_ticket, and a clientdata JSON describing the stages. This is the one artifact in this guide that could not be built through the Web API on 9.1 within a sane time-box — documented here honestly rather than faked.

The CLI attempt (and its exact failure)

crm --json entity create workflows --data-file /tmp/cwx_bpf.json
# body: { "name":"Ticket Resolution", "uniquename":"cwx_ticketresolution",
#         "category":4, "type":1, "primaryentity":"cwx_ticket", "languagecode":1033 }
{ "ok": false, "error": "An unexpected error occurred.",
  "meta": { "status": 500, "code": "0x80040216" } }

Why it's blocked on 9.1

  1. cwx_ticket.IsBusinessProcessEnabled is false, and MS Learn states "Enabling a table for business process flow is a one-way process. You can't reverse it." This guide does not force an irreversible metadata mutation over the API.
  2. clientdata has no documented hand-authorable schema. Reverse-engineering an existing on-org BPF shows it is a ~6.8 KB serialization of internal Microsoft.Crm.Workflow.ObjectModel classes (WorkflowStep → EntityStep → StageStep → StepStep → ControlStep) whose IDs must align with platform-generated processstage rows.
  3. MS Learn documents only the visual designer + a GET to retrieve the definition — no supported "create a BPF via Web API POST" path for on-prem 9.1.

Manual-portal fallback (the supported path)

Settings → ProcessesNew → Category Business Process Flow → Entity cwx_ticket → add stages New → In Progress → ResolvedActivate. Creating the BPF through the designer also performs the irreversible IsBusinessProcessEnabled flip on cwx_ticket for you.

BPF was not created via the CLI

Every other artifact in this guide was built live through crm. The BPF is the lone exception on 9.1: it requires the portal designer (or a managed-solution import). This is tracked in issue #37 ("BPF creation via Web API on D365 CE on-prem 9.1"); if a supported API recipe surfaces, a future crm bpf command can emit it. The app in §11 therefore binds the views, forms, chart and dashboard — not a BPF.

11. Sitemap & model-driven app

The capstone: an appmodules row (the app), a sitemaps row (its navigation), and the AddAppComponents action to bind everything built so far. Three on-prem-9.1 quirks were discovered live and each matters.

App module

appmodules requires a non-null webresourceid (the tile icon). The platform ships a default icon (953b9fac-1e5e-e611-80d6-00155ded156f, msdyn_/Images/AppModule_Default_Icon.png); CRMWorx instead uses its own cwx_ SVG web resource so the app is self-contained:

# 1. create + publish a cwx_ icon web resource (type 11 = SVG)
crm --json entity create webresourceset --data-file /tmp/cwx_webresource.json   # -> 9188010c-…
crm --json solution publish-all

appmodule create: use --no-return

The default Prefer: return=representation makes the POST read the new row straight back — but an unpublished appmodule isn't yet readable, so the whole call reports appmodule With Id = … Does Not Exist after having created the row (which then collides on the next attempt). Create with --no-return and read the id back with the RetrieveUnpublishedMultiple function instead.

crm --json entity create appmodules --no-return --data-file /tmp/cwx_app.json
crm --json query odata "appmodules/Microsoft.Dynamics.CRM.RetrieveUnpublishedMultiple()" --select name,uniquename,appmoduleid
# -> CRMWorx | cwx_crmworx | 79bdfbec-725e-f111-b65d-00155d467b90

/tmp/cwx_app.json: { "name":"CRMWorx", "uniquename":"cwx_crmworx", "clienttype":4, "navigationtype":0, "webresourceid":"9188010c-…" } (clienttype 4 = Unified Interface, confirmed against the org's existing apps).

Sitemap

A sitemaps row whose sitemapnameunique equals the app's uniquename auto-associates with the app. SiteMapXml: one Area → one Group → three SubAreas (the dashboard, then the two tables by Entity=):

<SiteMap>
  <Area Id="cwx_area_service" Title="CRMWorx" ShowGroups="true">
    <Group Id="cwx_group_main" Title="CRMWorx">
      <SubArea Id="cwx_sa_dashboard" Title="SLA Overview" DefaultDashboard="e29005b6-…" />
      <SubArea Id="cwx_sa_tickets" Entity="cwx_ticket" Title="Tickets" />
      <SubArea Id="cwx_sa_slas" Entity="cwx_sla" Title="SLAs" />
    </Group>
  </Area>
</SiteMap>
crm --json entity create sitemaps --no-return --data-file /tmp/cwx_sitemap.json   # -> 2ef3183b-…

Bind components

AddAppComponents takes typed entity references, not {Type, Id}

On 9.1 the Components array is a collection of entity references — each element is the component's primary-key field plus its @odata.type, e.g. { "savedqueryid":"…", "@odata.type":"Microsoft.Dynamics.CRM.savedquery" }. The componenttype integers (entity 1, view 26, chart 59, form 60, sitemap 62) are the read-side appmodulecomponent.componenttype values, not the action payload shape.

crm --json action invoke AddAppComponents --body-file /tmp/cwx_addcomponents.json

The body binds 8 components: the sitemap, the 3 views, the augmented main form + quick-create + dashboard (all systemform), and the chart. Then publish, validate, and read the components back. ValidateApp / RetrieveAppComponents are functions whose AppModuleId is an Edm.Guid — pass it unquoted by calling the function on the URL path (the CLI's --params would quote it as a string and the server rejects that):

crm --json solution publish-all
crm --json query odata "ValidateApp(AppModuleId=79bdfbec-…)"
crm --json query odata "RetrieveAppComponents(AppModuleId=79bdfbec-…)"
ValidationSuccess = True        # app is valid
RetrieveAppComponents -> 8 items (3 view, 1 chart, 3 form/dashboard, 1 sitemap)

Tables surface through the sitemap, not AddAppComponents

AddAppComponents only accepts record-backed components (its Components parameter is a crmbaseentity collection), so cwx_ticket / cwx_sla table metadata can't be bound through it. The two tables reach the app via the sitemap's Entity= subareas; ValidateApp notes this as a non-blocking warning and still returns ValidationSuccess = True.

12. Launch & verify

The app launches at:

http://internalcrm.contoso.local/Contoso/main.aspx?appid=79bdfbec-725e-f111-b65d-00155d467b90

The running app, captured live (headless Chromium over NTLM):

CRMWorx SLA Overview dashboard — the Tickets-by-Priority chart and Active Tickets list, both reading the §6 view

The dashboard (§9) renders both components: the "Tickets by Priority" chart (§8) and the "Active Tickets" list (§6), with the CRMWorx sitemap (§11) in the left nav — SLA Overview, Tickets, SLAs.

Active Tickets view — the §6 savedquery with its custom columns

The Tickets area shows the §6 "Active Tickets" view with exactly the columns its LayoutXml defined (Ticket Title, Priority, Severity, Customer, Created On, Status Reason).

Support Ticket form — the §7 augmented main form with all five custom fields

Opening a ticket shows the §7 clone-and-augmented main form: every injected field is present and populated — Priority, Severity, Category, the SLA lookup, and Customer.

NTLM in headless automation

A first navigation attempt failed with net::ERR_INVALID_AUTH_CREDENTIALS: Playwright suppresses the native NTLM prompt, so the browser needs the credentials supplied programmatically. These shots were taken by launching Chromium with --auth-server-allowlist=*contoso.local and an http_credentials context — the documented way to negotiate NTLM headless. Credentials were read from .env at run time and never appear in the capture.

The server-side read-back corroborates that the app and its components exist on the server regardless of the browser:

crm --json query odata appmodules --filter "uniquename eq 'cwx_crmworx'" --select name,appmoduleid,uniquename
crm --json query odata "RetrieveAppComponents(AppModuleId=79bdfbec-…)"
crm --json query odata "ValidateApp(AppModuleId=79bdfbec-…)"
CRMWorx | 79bdfbec-725e-f111-b65d-00155d467b90 | cwx_crmworx
RetrieveAppComponents -> 8 components
ValidateApp           -> ValidationSuccess = True

13. Promoted commands

The high-reuse, small-predictable-XML pieces — public views and the model-driven app/sitemap — are promoted from raw entity create calls into first-class command groups (crm view, crm app), each backed by requests_mock unit tests. Their generators emit exactly the shapes the live server accepted in §6 and §11.

crm view create rebuilds the "Active SLAs" view (here under a (cmd) suffix so it sits alongside the §6 original) — it generates the LayoutXml + FetchXml, guards on name+entity, creates, and publishes:

crm --json view create cwx_sla --name "Active SLAs (cmd)" --otc 10126 \
  --column "cwx_name:240" --column "cwx_tier:140" --filter-active --if-exists skip
{ "ok": true, "data": { "created": true,
  "savedqueryid": "467f3c88-785e-f111-b65d-00155d467b90", "published": true } }

crm app create is idempotent — run against the app from §11 it reports a skip via the same existence guard, no duplicate:

crm --json app create --name CRMWorx --unique-name cwx_crmworx --if-exists skip
{ "ok": true, "data": { "skipped": true,
  "appmoduleid": "79bdfbec-725e-f111-b65d-00155d467b90" } }

crm app add-components and crm app set-sitemap round out the group, emitting the typed-entity-reference AddAppComponents body and the sitemapnameunique-linked sitemap that §11 proved on 9.1. crm app create always sets the required webresourceid to the platform default icon.

Capability coverage

Every crm command group is exercised by this walkthrough:

Group Exercised by
connection §1 — whoami, connect, profiles, status
session session info (active profile, current entity set, last query)
metadata §2 — create-optionset, create-entity, add-attribute, create-one-to-many, create-many-to-many, relationships, list-optionsets, entities
entity §3 — create, update, upsert (lookups via @odata.bind); §6–§11 — create/update on savedqueries, systemforms, savedqueryvisualizations, webresourceset, appmodules, sitemaps (--no-return for appmodule/sitemap)
query §4 — odata + fetchxml; §6–§12 — odata over interface tables + RetrieveUnpublishedMultiple / ValidateApp / RetrieveAppComponents functions
data §4 — export (CSV)
action §4 — function RetrieveCurrentOrganization; §11 — invoke AddAppComponents
solution §5–§11 — publish-all, export, components, list, info
(interface) §6 views · §7 forms · §8 charts · §9 dashboard · §10 BPF (manual fallback, #37) · §11 sitemap + model-driven app · §12 launch
view §13 — crm view create (promoted savedquery generator)
app §13 — crm app create / add-components / set-sitemap (promoted appmodule + sitemap)

Teardown (optional — full reset for a clean replay)

Destructive. Each command requires --yes; the destructive_op_gate PreToolUse hook blocks them otherwise. Deleting an entity drops the table and every row in it. Run these only to reset the org for a clean replay — CRMWorx is otherwise left deployed.

Reverse order — interface artifacts first, then records-with-their-tables, then relationships and attributes go with the entities, leaving only the global option sets to delete last. The app module, global sitemap, and icon web resource are independent — they do not cascade when the entities are dropped, so delete them explicitly. Views, forms, the chart and the dashboard do cascade with their owning entity, so they need no separate delete.

# Interface teardown (before dropping the tables) — these don't cascade with the entity
crm --json entity delete appmodules 79bdfbec-725e-f111-b65d-00155d467b90 --yes   # the model-driven app
crm --json entity delete sitemaps   2ef3183b-735e-f111-b65d-00155d467b90 --yes   # the app sitemap
crm --json entity delete webresourceset 9188010c-725e-f111-b65d-00155d467b90 --yes  # the cwx_ app icon
# (views/forms/chart/dashboard are deleted automatically with their owning entity below)

crm --json metadata delete-entity cwx_ticket --yes   # drops the table + all rows + its relationships + its views/forms/chart/dashboard
crm --json metadata delete-entity cwx_sla --yes
crm --json metadata delete-optionset cwx_priority --yes
crm --json metadata delete-optionset cwx_severity --yes
crm --json metadata delete-optionset cwx_ticketcategory --yes
crm --json metadata delete-optionset cwx_slatier --yes

After teardown, crm --json metadata entities --custom-only | grep -c cwx_ returns 0. The entire guide then replays from clean by re-running §2 onward — every create is idempotent, so a partial replay is safe to resume.

This teardown + replay was run once against the live server to validate it: the deletes dropped both tables and all four option sets (entities → 0), then the full §2–§3 build was re-applied, leaving CRMWorx deployed with fresh metadata ids. One note — delete-entity has no --if-exists skip, so deleting an already-removed table returns an error (EntityMetadata ... does not exist) rather than a no-op; delete in the documented order and it won't recur.