Workstreams, queues, routing rulesets and opening hours are content, not structure - a managed solution never carries them. The Configuration Migration Tool is Microsoft's answer, and it was never really built for this shape of problem. Here's what that actually looks like at 4pm before a go-live, and the tool I built instead.
Ask anyone who has promoted Contact Center configuration through a real environment pipeline - Dev, Test, Acceptance, Production - what that afternoon actually looked like, and you'll get a wince before you get an answer.
Here is the honest version. You export a schema file naming every table you need: workstreams, queues, operating hours, decision contracts, routing rulesets, channel settings. You run the Configuration Migration Tool against source, and it hands you a data.xml full of records and GUIDs. You import it into target. Some of it works. The routing rulesets do not, because a ruleset's definition is a JSON blob that embeds direct GUID references to the queues and skills it routes to - and the Configuration Migration Tool has no idea those GUIDs are references at all. To it, they're just text inside a field. So you open data.xml in a text editor, find every GUID for every referenced queue by hand, and replace it with target's own GUID for that queue. Then you import again, because the dependency mappings only land correctly on the second pass. Then you wait, because creating a workstream kicks off asynchronous provisioning with no visible progress and no obvious "done" signal. Then you discover the operating-hours calendar rows did not come along for the ride, because those are a separate table CMT does not know to chase. That's not an edge case. That is a normal Tuesday.
None of this is really CMT's fault. It's a generic tool solving a problem it was never shaped for.
Why the Configuration Migration Tool falls apart here
A Dataverse solution carries structure: forms, views, plugins, the shape of a table. It does not carry data. Workstreams, queues, routing rulesets, operating hours — these are records, not structure, so a managed solution moving through your pipeline never touches them. The Configuration Migration Tool exists precisely to fill that gap: export a set of records from one environment, import them into another. For most tables, that works fine.
Contact Center config is not most tables. Three things make it specifically brutal:
- Embedded references, not structured lookups. A decision ruleset's routing logic lives in
msdyn_rulesetdefinition, a single text field holding a JSON document that references queues, skills and other rulesets by raw GUID. CMT remaps GUIDs it recognizes as lookup columns. It cannot see inside a JSON blob, so those references travel across environments unchanged - pointing at GUIDs that mean nothing in target, or worse, happen to collide with something that does exist there. - A required second pass. Ruleset dependency mappings do not land correctly on the first import. The documented fix is to import twice. Nobody discovers this by reading a wizard; they discover it by staring at a ruleset that silently didn't route anything, then finding the forum thread.
- Asynchronous provisioning with no visibility. Creating a workstream fires off provisioning you cannot query directly. You wait, hope, and check back later. If you promote the next table before it's done, you get to find out the hard way.
Every one of these is a reasonable design choice for a general-purpose data migration tool. None of them was designed with Contact Center's specific shape in mind - composite natural keys with no registered alternate key, embedded GUIDs disguised as plain text, async side effects on create. That mismatch is where every hour of a Contact Center go-live actually goes.
What I built instead
Contact Center ALM is a Power Apps code app that runs inside Dynamics, backed by an Azure Function that does the cross-environment work under a service principal. You pick a source and a target. It reads both, diffs them, and shows you what's different - before anything moves.
That single change removes most of the pain by itself. Two identical workstreams in two different environments have completely different GUIDs and completely identical names - so matching on GUID makes every promotion look like "everything changed," and matching on name makes an unchanged workstream read as exactly what it is: unchanged. The tool builds that natural key per table from the manifest, resolving lookup columns to their own natural key recursively, so a routing configuration matched on "name plus parent workstream" still lines up across environments even though the workstream's own GUID is different in each one.
Every GUID embedded in a ruleset definition is tracked as a real dependency, the same as an explicit lookup column. Select a ruleset and the queues and workstreams it depends on get selected with it. If one of those dependencies genuinely isn't part of the run, the write for that specific record is skipped with a warning that names exactly what it's waiting on - not written half-broken, and not silently wrong.
Parents land before the records that reference them, in a fixed order across ten stages - foundations, then workstreams, then queues, then decision contracts, then rulesets, and on through channel settings. Every apply snapshots the rows it's about to touch, so a run that goes wrong can be rolled back. And every apply defaults to a dry run: it reports exactly what would happen and writes nothing, so you see the plan before you commit to it.
What that actually looks like
These screenshots are the sample data set the app ships with - no live environment, just enough sample workstreams, queues and rulesets to show the real flow end to end.


Requires Operating hours: Weekend support — tick the workstream and its dependency comes with it. Above it, the tool has already flagged an ACS phone number and a Copilot Studio agent as per-environment provisioning it won't touch.

What surprised me building this
A few things I was confident about going in turned out to be wrong, in ways that only showed up against a real environment.
- 01 Alternate keys looked like the correct Dataverse-native way to do this.Build the upsert URL from a registered alternate key, let Dataverse handle idempotency. Except the alternate key I assumed was registered, wasn't in this org, or possibly any org, since nothing requires Contact Center tables to publish one. The write engine now decides create-vs-update purely from the natural-key match it already found during compare. No metadata assumption to be wrong about.
- 02 Skipping one boolean field quietly turned every apply into a duplicate factory. A queue's "is this a real Omnichannel queue or an auto-provisioned personal one" flag was left out of the write payload — harmless, since it seemed like a read-only signal. Except Dataverse defaults a newly created queue to personal when the field is omitted, which made that queue invisible to the very next comparison's personal-queue filter. Nothing errored. Every apply just quietly created another one.
- 03 Natural-key matching breaks the moment a key includes a lookup - unless you resolve it recursively. A routing configuration matched on "name plus parent workstream" needs that workstream reference resolved to its own name, not left as a raw GUID. Miss that one level of recursion and you're comparing two random GUIDs from two different environments and calling it a match, which is exactly the problem this tool exists to not have.
What it doesn't do
- Writing routing and channel config through the raw Dataverse Web API is not a Microsoft-supported path. It's how CMT's own documented process works too - a manual
data.xmledit - just automated. Test after every product update. - ACS phone numbers, Copilot Studio voice agents, Teams and WhatsApp channel accounts, Customer Voice surveys - all genuinely per-environment provisioning. The tool detects and reports these. It does not try to move them, because they can't be moved, by anyone, ever.
- Rollback restores the records a run touched. It cannot un-provision a workstream's async side effects. A half-provisioned workstream still needs a human to look at it.
Try it
It's free, it's open source. Point it at Dev and Test first. Dry-run everything. Trust it on Test before you trust it on Prod.
Want a hand running this for real?
I'm the Global Microsoft Dynamics 365 Contact Center lead at Capgemini. If you're staring down a real go-live and want it planned and run properly - not just pointed at a free tool - that's exactly the conversation to have. Contact me here!
Comments