Sam's Dynamics, Power Platform & AI Blog

Thursday, 17 September 2026

Testing a Copilot agent: evaluation, not "it seemed to work"



Given input, expect output works for a plugin. Agent responses depend on prompt, retrieval and context interpretation, so you measure response quality and task alignment, not correctness.


Test sets. Built into Copilot Studio. Up to 100 cases per set — hand-written, spreadsheet import, or AI-generated. Quick set: 10 questions from the agent's description and instructions. Full set: up to 100 from knowledge sources or topics.

The generator reaches knowledge sources using the connected account's credentials, so generated cases can contain sensitive data that account can see. Review before sharing the set.

Grading. Every method except general quality needs expected responses or keywords — writing down what a good answer looks like is the actual work. Custom Graders (classification method) encode your own policies where built-in dimensions don't fit.

Test user profiles. Evaluations run under a designated test account, and that account connects to knowledge sources and tools during the run. Testing as yourself proves nothing about what a sales rep sees. Simulate profiles to check behaviour across roles and access levels.

Limits: GCC can't add user profiles to test sets and doesn't support the similarity method. User-auth evaluations need the Copilot Studio connector enabled.

Automation. Results live 89 days — export CSV for anything auditable. REST API triggers evaluations programmatically for release validation and CI/CD regression runs.

Copilot Studio Kit goes deeper: tests via Direct Line API, enriched from Application Insights and Dataverse transcripts, so you get triggered topic and intent recognition scores behind each pass or fail. Multi-turn tests, and pipeline gating — deploy pauses, tests run, thresholds checked, then promote.

What goes in the set:

  • Questions users actually ask, badly phrased ones included
  • Every bug that ever shipped, permanently
  • Questions the agent should refuse or escalate
  • The same question from two profiles where correct answers differ
  • Anything near column-secured fields

Correction to the ALM post. I said keep a manual list of twenty questions and run it after each deploy. Right instinct, wrong implementation — put them in a test set, grade them, run them from the pipeline.

Related: Copilot in Dynamics 365 — what it actually does


Thanks for reading this article. Hope this Article will help you. Cheers!!!

#Dynamics365 #PowerPlatform #MicrosoftCopilot #DynamicsCRM #CopilotStudio #Dataverse #AIDeveloper #DynamicsAIEngineer #HireAIEngineer #AIEngineer #D365Consultant #CRMDeveloper #AIAgents #MSDyn365

Monday, 14 September 2026

Packaging and Deploying Web Resources: A Practical ALM Workflow for Dynamics 365 JavaScript

You've written the script. You've unit-tested the logic and stepped through it in dev tools. It works perfectly on your form. Now comes the part that trips up more Dynamics 365 projects than any bug ever does: getting that script safely out of your dev environment and into production without breaking something else on the way.

This post is about the plumbing — solutions, environment variables, and a repeatable release process — rather than more JavaScript syntax. If you've been following the series, think of it as the missing link between "my script works" and "my script is live, and I can update it again next sprint without a two-hour outage window."

Web resources live in a solution — always

A web resource that exists only in your dev environment isn't deployable. The first rule of ALM in Dynamics 365 is simple: every JavaScript file, every image, every HTML web resource you build has to sit inside an unmanaged solution in dev, so it can be exported and carried forward.

Create one solution per project or product area — not the Default Solution — with your own publisher and a prefix:

Publisher: Contoso CRM Team
Prefix: con_

Web resource name: con_/scripts/opportunity.form.js
Web resource name: con_/scripts/lib/discountLogic.js

Notice the folder-style naming. Dynamics doesn't enforce folders, but the "/scripts/", "/styles/", "/images/" convention inside the name keeps a solution with 40+ web resources from turning into an unreadable flat list. Pick a convention on day one — renaming a web resource later means updating every form event handler that references it by name.

Stop hardcoding URLs and GUIDs

This is the single most common thing I see go wrong in a promotion from dev to test to production: a script with a hardcoded environment URL, flow trigger endpoint, or record GUID baked directly into the code.

// don't do this
const flowUrl = "https://prod52a1.environment.api.powerplatform.com/powerautomate/...";
const escalationTeamId = "3f9a1c20-...-...-...-prodonly";

That script works in production and breaks the moment someone imports the same managed solution into UAT, because the flow URL and team GUID are different there. Use environment variables instead — a Dataverse table type built exactly for this. Define the environment variable in your solution, give it a default value for dev, and set the current value per environment after import (test, UAT, and prod each get their own).

// do this instead
async function getEnvVarValue(schemaName) {
  const result = await Xrm.WebApi.retrieveMultipleRecords(
      "environmentvariabledefinition",
          `?$filter=schemaname eq '${schemaName}'&$expand=environmentvariabledefinition_environmentvariablevalue($select=value)`
            );
              const def = result.entities[0];
                const values = def.environmentvariabledefinition_environmentvariablevalue;
                  return values && values.length ? values[0].value : def.defaultvalue;
                  }
                  
                  const flowUrl = await getEnvVarValue("con_DiscountApprovalFlowUrl");

Yes, it's a couple more lines than a hardcoded string. It's also the difference between a 30-second solution import and a support ticket at 5pm on a Friday because someone forgot to swap a URL by hand.

A release path you can actually repeat

The environments-and-arrows diagram is familiar to anyone who's worked on a real project, but the part people skip is making it boring — the same steps, every time, ideally without a human retyping anything:

Dev (unmanaged)
  → export as MANAGED solution
      → import into Test/UAT
            → validate, set environment variable values
                    → export the same version as MANAGED
                              → import into Production
                                          → set production environment variable values

A few rules that keep this from going sideways:

Only your dev environment is unmanaged. Every downstream environment gets a managed solution. Managed solutions can't be casually edited in the target environment — which is exactly what stops someone from "just quickly" fixing a bug directly in production and having it silently overwritten by the next real release.

Bump the solution version every release (1.0.0.3, 1.0.0.4...) so you can tell at a glance what's actually deployed in each environment, and so a re-import doesn't get silently skipped as "no changes detected."

Never hand-edit a web resource inside the target environment's UI. If test or production needs a change, it goes back through dev, gets re-exported, and flows down the same path. The moment someone edits a web resource directly in production "just this once," your source of truth splits in two.

Keep the actual JavaScript in source control

The solution zip is not source control. Treat the Dataverse web resource as a deployment target, not the place your code lives. The workflow that scales:

1. Write/edit the .js file in your repo (VS Code, real linting, real diffs)
2. Use the Power Platform CLI to push it straight to your dev web resource:

   pac webresource push --file ./src/opportunity.form.js `
        --solution ConCrmTeamSolution --publisher con
        
        3. Commit the .js file to git like any other source file
        4. When it's time to release, export/pack the SOLUTION (not the .js file) via:
        
           pac solution export --name ConCrmTeamSolution --managed
              pac solution unpack --zipfile ConCrmTeamSolution.zip --folder ./solution --packagetype Managed

Once the unpacked solution folder is in git alongside your web resource source, a pull request actually shows you a meaningful diff — not a base64 blob — and code review on a form script becomes possible for the first time.

Cache is the silent killer of "but it worked in dev"

You imported the solution, the web resource clearly has your new code in the customizations, and the form still runs the old logic. Nine times out of ten, that's the browser serving a cached copy of the .js file. A few things that actually fix it, in order of how often I reach for them:

Do a hard refresh (Ctrl+Shift+R / Cmd+Shift+R) on the form before assuming your deployment failed. If it's happening to your whole team repeatedly after every release, increment a version query string convention in how the web resource is referenced, or use the "Publish All Customizations" step deliberately — a solution import doesn't always auto-publish everything, and an unpublished web resource will serve the old version even though the record itself shows the new content.

A short pre-flight checklist

Before you ship a form script anywhere past dev, it's worth running down a short list rather than trusting memory:

Is the web resource actually included in the solution (added web resources don't auto-include just because they exist)? Are there any hardcoded URLs, GUIDs, or environment-specific values left in the code? Does the target environment already have its environment variable values set, or will the script silently fail on first load after import? Did you bump the solution version? And — the one everyone forgets at least once — did you publish?

Where this leaves you

None of this is exciting the way a working Copilot Studio integration is, but it's the reason that integration keeps working three months from now, across three environments, maintained by more than one person. A script that only exists correctly in your head and your dev environment isn't shipped — it's a demo.

Next in the series, we'll go back to the form itself and look at ribbon and command bar customization with JavaScript — enable rules, custom buttons, and the display rules that decide when a command even shows up.

Ribbon and Command Bar Customization with JavaScript in Dynamics 365

Everything so far in this series has lived inside the form body — fields, lookups, Web API calls, agent responses. This post moves up one level, to the command bar sitting above all of it: the Save button, the ribbon buttons, the custom "Send for Approval" or "Escalate to Manager" buttons that show up (or don't) depending on the state of the record. That behavior is driven by JavaScript too, and it trips people up in a different way than form scripting does, because the wiring is less obvious.

Two ways to control a button, and why this post is about the JS one

Dynamics 365 gives you two paths to command bar logic. The modern command bar designer lets you write simple Power Fx-style formulas directly against a button's Visible and Enabled properties — no code, no web resource, good enough for straightforward conditions. The classic path — Enable Rules and Display Rules backed by a JavaScript function — is what you reach for when the condition is genuinely complex: multiple entity checks, a Web API call to decide visibility, or logic you want to unit test the same way you'd test any other function in this series.

This post covers the JavaScript path, because that's where the interesting problems are.

Enable Rules vs Display Rules — they are not the same thing

A Display Rule decides whether a button appears at all. A Enable Rule decides whether a button that's already visible can be clicked — a visible-but-greyed-out state. Mixing these up is the single most common ribbon bug: a button that a user can see and click, but that silently does nothing because the actual intent was to hide it until certain conditions were met, not just enable it.

Both rule types point at a JavaScript function with the same signature — Dynamics calls it a CommandChecker function — and both must return a plain boolean.

function canSendForApproval(primaryControl) {
  var formContext = primaryControl;
    var status = formContext.getAttribute("statuscode").getValue();
      var isDirty = formContext.data.getIsDirty();
      
        // only show/enable once saved and still in Draft
          return status === 1 && !isDirty;
          }

Notice the parameter isn't literally "the form" — it's whatever you configured as a CRM Parameter when you wired the rule up in the Ribbon Workbench (or classic solution ribbon editor). PrimaryControl is the one you'll use constantly, because it gives you the same formContext-shaped object you already know from every other post in this series — getAttribute, getValue, ui, all of it.

A realistic enable rule: role- and state-aware

Real conditions are rarely just one field check. Here's a button that should only be enabled for the record owner, only once the opportunity has reached the Propose stage, and only if there's no pending approval already:

async function canEscalate(primaryControl) {
  var formContext = primaryControl;
    var userId = Xrm.Utility.getGlobalContext().userSettings.userId;
      var ownerId = formContext.getAttribute("ownerid").getValue();
      
        if (!ownerId || ownerId[0].id.toLowerCase() !== userId.toLowerCase()) {
            return false;
              }
              
                var recordId = formContext.data.entity.getId().replace(/[{}]/g, "");
                  var result = await Xrm.WebApi.retrieveRecord(
                      "opportunity", recordId, "?$select=salesstage,con_hasopenescalation"
                        );
                        
                          return result.salesstage === 2 && !result.con_hasopenescalation;
                          }

CommandChecker functions can be async — Dynamics will wait for the promise before deciding the button's state. Just be aware that every extra Web API call in a rule adds latency to ribbon rendering, especially on grids with dozens of visible rows each re-evaluating their own rule. Keep the check as cheap as you can; if you find yourself calling the Web API from an enable rule on a subgrid, it's often faster to precompute that value onto the record itself (a rollup field or a plugin-set field) and check it directly, rather than querying live from the ribbon.

What actually happens on click

The rule only controls visibility and enablement — a separate function, wired up as the button's Action, is what runs when someone actually clicks it:

async function escalateToManager(primaryControl) {
  var formContext = primaryControl;
    var confirm = await Xrm.Navigation.openConfirmDialog({
        title: "Escalate this opportunity?",
            text: "Your manager will be notified and the record flagged for review.",
              });
              
                if (!confirm.confirmed) return;
                
                  await formContext.data.save();
                    await Xrm.WebApi.updateRecord(
                        "opportunity",
                            formContext.data.entity.getId().replace(/[{}]/g, ""),
                                { con_hasopenescalation: true }
                                  );
                                  
                                    formContext.ui.refreshRibbon();
                                    }

That last line matters more than it looks. Ribbon state doesn't automatically re-evaluate just because you changed a value in code — Dynamics re-checks your rules on load, on save, and on a few built-in events, but not every time an attribute changes. If your action changes something an enable rule depends on, call formContext.ui.refreshRibbon() yourself, or the button will sit in its old state until the user manually refreshes the page.

Where the JavaScript actually lives

Same rule as every other post in this series: the function has to be in a web resource that's part of your solution, and the Ribbon Workbench (or the classic ribbon customization) references it by $webresource: path plus function name — not by file path alone. A function that works perfectly when you test it in the console but does nothing on the actual button almost always means one of three things: the web resource reference in the ribbon XML is wrong, the function isn't actually included in that web resource, or you edited the JS but forgot to publish afterward — the same publish-and-cache trap from the ALM post shows up here too.

Testing rules without clicking through the UI fifty times

Because CommandChecker functions take a plain object shaped like formContext and return a boolean, they're some of the easiest ribbon-adjacent code to unit test — you don't need a real form, just an object with the methods your function actually calls:

test("escalate disabled when not the owner", async () => {
  const fakeForm = {
      getAttribute: () => ({ getValue: () => [{ id: "other-user-guid" }] }),
        };
          expect(await canEscalate(fakeForm)).toBe(false);
          });

This is the same pattern from the testing and debugging post — mock the pieces of formContext your function actually touches, and you can validate ribbon logic in milliseconds instead of navigating to a record, checking a button state, changing data, and reloading, over and over.

A short checklist before you ship a ribbon change

Did you use a Display Rule where you meant "hide it" and an Enable Rule where you meant "show it but grey it out" — not the other way around? Is any Web API call inside an enable rule cheap enough to run on every grid row, not just a form? Does your click-handler call refreshRibbon() if it changes something another rule depends on? And is the web resource holding these functions actually included in the solution you're about to export — the same ALM discipline from a couple of posts back applies just as much to ribbon code as it does to form scripts.

Next up

We've covered forms, data, agents, testing, deployment, and now the command bar. Next in the series: Business Process Flow JavaScript — reading and moving the active stage in code, and the specific quirks of scripting against a BPF versus a regular form.

From Script to Agent: Wiring a Copilot Studio Action into a Dynamics 365 Ribbon Button

We've spent this series in the form: events, field logic, lookups, subgrids, and calling the Web API. This last post is where it connects to the other thing you're probably building right now — a JavaScript-driven ribbon button that hands off to a Copilot Studio agent and brings the answer back onto the form.

The Pattern: Ribbon Button → JavaScript → Agent

There's no direct client-side SDK call from form JavaScript into a Copilot Studio agent. The reliable pattern is: a ribbon button runs a JavaScript function, that function calls an HTTP-triggered Power Automate flow (or an Azure Function) that invokes the agent's topic, and the response comes back as JSON that your script uses to update the form or show a notification.

Step 1: Add the Ribbon Button

Using the modern command bar designer (Power Apps maker portal → table → Forms → Command bar) or Ribbon Workbench, add a button with a JavaScript action pointing to a function in a form library, e.g. new_/ribbon/agentActions.js, function summarizeCaseWithAgent. Pass PrimaryControl as a parameter so your function gets the form context.

Step 2: Expose the Agent as a Callable Endpoint

In Copilot Studio, publish the topic you want to trigger, then wrap it with a Power Automate flow that starts with an HTTP request trigger, calls the agent (or the underlying logic the agent uses), and responds with the result. This gives you a stable HTTPS URL your form script can call — the same pattern used for any external integration, just fronting an agent instead of a plain API.

Step 3: Call It from the Button's JavaScript

async function summarizeCaseWithAgent(primaryControl) {
    var formContext = primaryControl;
        formContext.ui.setFormNotification("Asking the agent for a summary...", "INFO", "agent_call");
        
            var caseId = formContext.data.entity.getId().replace("{", "").replace("}", "");
                var description = formContext.getAttribute("description").getValue();
                
                    try {
                            var response = await fetch("https://your-flow-endpoint.example.com/trigger", {
                                        method: "POST",
                                                    headers: { "Content-Type": "application/json" },
                                                                body: JSON.stringify({
                                                                                caseId: caseId,
                                                                                                description: description
                                                                                                            })
                                                                                                                    });
                                                                                                                    
                                                                                                                            if (!response.ok) {
                                                                                                                                        throw new Error("Flow returned " + response.status);
                                                                                                                                                }
                                                                                                                                                
                                                                                                                                                        var result = await response.json();
                                                                                                                                                                formContext.ui.clearFormNotification("agent_call");
                                                                                                                                                                        formContext.ui.setFormNotification(result.summary, "INFO", "agent_summary");
                                                                                                                                                                            } catch (error) {
                                                                                                                                                                                    formContext.ui.clearFormNotification("agent_call");
                                                                                                                                                                                            formContext.ui.setFormNotification("Couldn't reach the agent right now.", "ERROR", "agent_error");
                                                                                                                                                                                                    console.error(error);
                                                                                                                                                                                                        }
                                                                                                                                                                                                        }

A Practical Example: "Summarize This Case" Button

The function above is a working version of this: it reads the case description, calls the flow, and shows the agent's summary as a form notification instead of navigating anywhere. You can just as easily write the result into a field, or open it in a side panel — whatever fits the workflow.

A Word on Security

The agent's flow runs under its own connection identity, not the signed-in user's — so it can potentially see more (or less) than the user calling it. That's a topic on its own, and worth checking against your Dataverse security roles before you wire up anything that touches sensitive data.

Common Pitfalls

  • No loading state. Agent calls can take a few seconds; always show a notification (or disable the button) while waiting, so users don't click twice.
  • No timeout handling. Set a reasonable timeout on the fetch call and handle it gracefully — an agent call that hangs shouldn't hang the form.
  • Trusting the response shape blindly. Validate the JSON structure before reading fields off it; an agent or flow change can silently alter the response.
  • Hardcoding the endpoint URL. Store it in an environment variable or a configuration record instead, so it survives a move between environments.

Wrapping Up the Series

Five posts in, the throughline hasn't changed: form scripting is still the layer that makes everything else — Business Rules, Power Automate, Copilot agents — actually usable inside the form users work in every day. Knowing both the fundamentals and where they connect to AI is what keeps a CRM developer's skill set current without starting over. Thanks for following along.

Testing and Debugging Dynamics 365 Form Scripts: A Practical Workflow

Five posts in, you can write form scripts that handle events, field logic, lookups, subgrids, Web API calls, and even agent handoffs. None of that matters much if you can't quickly tell why a script isn't working, or catch it breaking before a user does. This post is about the workflow around the code — debugging, logging, and a lightweight way to test the logic outside the form entirely.

Debugging in the Browser

Skip alert() debugging. Open dev tools (F12), go to Sources, and find your web resource under the model-driven app's origin (usually nested under something like /webresources/). Set a real breakpoint by clicking the line number, then trigger the event from the form — execution will pause there with full access to inspect formContext, step through, and watch variables change.

function onChangeHandler(executionContext) {
    debugger; // pauses here automatically when dev tools are open
        var formContext = executionContext.getFormContext();
            // ...
            }

A Lightweight Logging Pattern

console.log calls scattered through production code get noisy and get left in by accident. A small wrapper keeps things consistent and easy to strip out or redirect later:

var Logger = {
    enabled: false, // flip on only while diagnosing an issue
    
        info: function (message, data) {
                if (Logger.enabled) console.log("[Form Script] " + message, data || "");
                    },
                        error: function (message, error) {
                                console.error("[Form Script] " + message, error);
                                        // optionally: send to Application Insights or a Dataverse log table here
                                            }
                                            };
                                            
                                            // Usage
                                            Logger.info("Status changed", formContext.getAttribute("statuscode").getValue());

Errors always log regardless of the flag — you want those visible in production. Informational logs stay quiet unless you're actively debugging.

Common Runtime Errors and What They Actually Mean

  • "Cannot read properties of undefined/null (reading 'getValue')" — getAttribute() returned null, almost always because that field isn't on the current form. Always check before calling methods on it.
  • "formContext.getControl(...).setVisible is not a function" — you're calling a control method on an attribute, or vice versa. Attributes hold values; controls are the UI.
  • Nothing happens, no error at all — usually the event isn't registered correctly (check "Pass execution context as first parameter" is ticked), or the library isn't added to this particular form.
  • Works in the maker portal preview, fails for users — security role differences. Test with a real (non-admin) user's role before calling it done.

Unit Testing with Jest (Mocking Xrm)

You don't need a live environment to test pure logic. Extract the decision-making into a plain function and mock the pieces of formContext it touches:

// discountLogic.js - the logic under test, extracted from the form script
function needsDiscountReason(discountPercent, customerType) {
    return discountPercent > 10 && (customerType === 1 || customerType === 2);
    }
    module.exports = { needsDiscountReason };
    
    // discountLogic.test.js
    const { needsDiscountReason } = require("./discountLogic");
    
    test("requires a reason above 10% for customer types 1 and 2", () => {
        expect(needsDiscountReason(15, 1)).toBe(true);
            expect(needsDiscountReason(15, 3)).toBe(false);
                expect(needsDiscountReason(5, 1)).toBe(false);
                });

This won't test the Dataverse integration itself, but it catches logic bugs instantly, without opening a browser — and it's the part of your script most likely to have an off-by-one or wrong-operator bug.

A Quick Smoke-Test Checklist Before You Publish

  • Test on a new, unsaved record as well as an existing one
  • Test with a non-administrator security role, not just your own
  • Test on the mobile app if the form is used there
  • Clear the browser cache or hard-refresh — stale web resource caching hides real bugs
  • Check the console for errors even when the feature "looks" like it worked

Common Pitfalls

  • Leaving debug logging on in production. Flip the Logger flag off (or gate it behind a config value) before publishing.
  • Testing only the happy path. Try blank fields, unexpected option set values, and records that predate the script.
  • Trusting the maker portal preview alone. It often runs with elevated privileges the real users won't have.

Wrapping Up

A script that works once in your own testing isn't the same as a script that's actually reliable. Debugging properly, logging deliberately, and testing the logic outside the form catches the bugs before your users do — which matters even more once your scripts start calling out to Web APIs and agents that can fail in their own ways too.

Calling the Dataverse Web API from Dynamics 365 Form Scripts

Everything so far in this series has stayed on the form: fields, events, lookups, subgrids. This post steps off the form and into Dataverse directly, using the Web API to read and write records the form doesn't already have loaded — the foundation you need before the next post, where this starts connecting to Copilot Studio agents.

Why Call the Web API from a Form Script

A few common reasons: pulling in data that isn't on the form (like a related account's credit limit), creating a related record without navigating the user away, running a real-time check against other records before save, or calling a custom Dataverse action. All of it goes through Xrm.WebApi, and every method returns a Promise.

The Basics: Xrm.WebApi

// Read one record
Xrm.WebApi.retrieveRecord("account", accountId, "?$select=name,creditlimit");

// Read multiple records
Xrm.WebApi.retrieveMultipleRecords("contact", "?$select=fullname&$filter=parentcustomerid_value eq " + accountId);

// Create a record
Xrm.WebApi.createRecord("task", {
    subject: "Follow up",
        "regardingobjectid_account@odata.bind": "/accounts(" + accountId + ")"
        });
        
        // Update a record
        Xrm.WebApi.updateRecord("account", accountId, { creditlimit: 50000 });

Note the entity set names are plural and lowercase (accounts, contacts), but retrieveRecord/createRecord/updateRecord take the singular logical name (account) — it's a common source of confusion.

Reading a Related Record

Here's a real example: when a contact form loads, pull the parent account's credit limit and show it in a notification:

async function onLoad(executionContext) {
    var formContext = executionContext.getFormContext();
        var accountValue = formContext.getAttribute("parentcustomerid").getValue();
            if (!accountValue) return;
            
                var accountId = accountValue[0].id.replace("{", "").replace("}", "");
                
                    try {
                            var result = await Xrm.WebApi.retrieveRecord("account", accountId, "?$select=creditlimit");
                                    formContext.ui.setFormNotification(
                                                "Account credit limit: " + (result.creditlimit || 0),
                                                            "INFO",
                                                                        "credit_limit_info"
                                                                                );
                                                                                    } catch (error) {
                                                                                            console.error("Failed to retrieve account credit limit", error);
                                                                                                }
                                                                                                }

Creating a Related Record

async function logFollowUpTask(executionContext) {
    var formContext = executionContext.getFormContext();
        var contactId = formContext.data.entity.getId().replace("{", "").replace("}", "");
        
            var task = {
                    subject: "Follow up on discount request",
                            "regardingobjectid_contact@odata.bind": "/contacts(" + contactId + ")"
                                };
                                
                                    try {
                                            await Xrm.WebApi.createRecord("task", task);
                                                } catch (error) {
                                                        console.error("Failed to create follow-up task", error);
                                                            }
                                                            }

A Practical Example: Real-Time Duplicate Check on Save

Combining an async Web API call with OnSave, including blocking the save until the check completes:

formContext.data.entity.addOnSave(onSaveCheckDuplicate);

async function onSaveCheckDuplicate(executionContext) {
    var eventArgs = executionContext.getEventArgs();
        var formContext = executionContext.getFormContext();
            var email = formContext.getAttribute("emailaddress1").getValue();
            
                if (!email) return;
                
                    eventArgs.preventDefault(); // pause the save while we check
                    
                        try {
                                var query = "?$select=contactid&$filter=emailaddress1 eq '" + email + "'";
                                        var result = await Xrm.WebApi.retrieveMultipleRecords("contact", query);
                                        
                                                var isDuplicate = result.entities.some(function (c) {
                                                            return c.contactid !== formContext.data.entity.getId().replace("{", "").replace("}", "");
                                                                    });
                                                                    
                                                                            if (isDuplicate) {
                                                                                        formContext.ui.setFormNotification("A contact with this email already exists.", "ERROR", "dup_check");
                                                                                                } else {
                                                                                                            formContext.data.entity.save(); // safe to save now
                                                                                                                    }
                                                                                                                        } catch (error) {
                                                                                                                                console.error("Duplicate check failed", error);
                                                                                                                                        formContext.data.entity.save(); // don't block save on a broken check
                                                                                                                                            }
                                                                                                                                            }

Common Pitfalls

  • Forgetting await. Without it, your code moves on before the API call resolves, and you end up reading undefined data or saving before a check completes.
  • Not handling rejected promises. A network error or a security-role denial throws — always wrap calls in try/catch.
  • Wrong entity set name. retrieveMultipleRecords needs the plural set name; retrieveRecord/createRecord/updateRecord need the singular logical name.
  • Too many calls per save. Each Web API call is a real HTTP request; batch or cache where you can instead of calling it inside a loop.

What's Next

Last post in this series: wiring a Copilot Studio agent action into a JavaScript-driven ribbon button — where form scripting and agents meet.

Working with Lookups, Option Sets, and Subgrids in Dynamics 365 Forms Using JavaScript

So far in this series we've covered basic form events and field-level show/hide/required logic. This post covers the three field types that trip people up most: lookups, option sets, and subgrids — including how to filter a lookup so users only see relevant records.

Reading and Setting Lookup Values

A lookup's value is always an array of objects, even though it only ever holds one record on most forms. Each object has id, name, and entityType:

var formContext = executionContext.getFormContext();
var lookupValue = formContext.getAttribute("parentcustomerid").getValue();

if (lookupValue) {
    var id = lookupValue[0].id;
        var name = lookupValue[0].name;
            var entityType = lookupValue[0].entityType;
            }
            
            // Setting a lookup value
            formContext.getAttribute("parentcustomerid").setValue([
                {
                        id: "00000000-0000-0000-0000-000000000000",
                                name: "Contoso Ltd",
                                        entityType: "account"
                                            }
                                            ]);

Filtering a Lookup's Results

By default a lookup searches the whole entity. To narrow it — say, only show contacts belonging to the account selected elsewhere on the form — use addPreSearch with a custom filter:

function onLoad(executionContext) {
    var formContext = executionContext.getFormContext();
        formContext.getControl("primarycontactid").addPreSearch(filterContactsByAccount);
        }
        
        function filterContactsByAccount(executionContext) {
            var formContext = executionContext.getFormContext();
                var accountValue = formContext.getAttribute("parentaccountid").getValue();
                
                    if (accountValue) {
                            var accountId = accountValue[0].id.replace("{", "").replace("}", "");
                                    var filter = "<filter type='and'>" +
                                                "<condition attribute='parentcustomerid' operator='eq' value='" + accountId + "' />" +
                                                            "</filter>";
                                                                    formContext.getControl("primarycontactid").addCustomFilter(filter, "contact");
                                                                        }
                                                                        }

One gotcha: addCustomFilter only applies to the next search, so it has to run inside addPreSearch every time, not just once on load.

Working with Option Sets

Option sets (choice fields) store and return the underlying numeric value, not the label. Use getOption or getOptions when you need the label or want to build the list dynamically:

var attribute = formContext.getAttribute("new_prioritylevel");

// Read/write the numeric value
var currentValue = attribute.getValue(); // e.g. 2
attribute.setValue(1);

// Get the label for the current value
var options = attribute.getOptions();
var selected = options.find(function (o) { return o.value === currentValue; });
console.log(selected ? selected.text : "none selected");

// Remove an option a user shouldn't be able to pick
var control = formContext.getControl("new_prioritylevel");
control.removeOption(3);

Refreshing a Subgrid

Subgrids don't automatically know when related data changes elsewhere on the form. Refresh one explicitly after an action that affects its rows:

var gridControl = formContext.getControl("Subgrid_Orders");
if (gridControl) {
    gridControl.refresh();
    
        // Reading the currently loaded rows
            var grid = gridControl.getGrid();
                var rows = grid.getRows();
                    console.log("Row count: " + rows.getLength());
                    }

Common Pitfalls

  • Treating a lookup value as a single object. It's always an array — forgetting the [0] is one of the most common form-script bugs.
  • Comparing option set values as strings. They're numbers; "2" === 2 is false in JavaScript.
  • Custom filters that don't reset. If you conditionally apply a filter, make sure the "else" branch clears it, or users will get a stale filter after changing the related field.
  • Calling grid methods before the subgrid has loaded. On OnLoad, wrap subgrid access in the control's onLoad event rather than assuming it's ready immediately.

What's Next

Next post: calling the Dataverse Web API directly from a form script — reading related records, creating child rows, and handling the async responses properly.

Field-Level Logic in Dynamics 365 Forms: Show/Hide, Enable/Disable, and Required Levels with JavaScript

In the first post of this series, we got a form script loading and reacting to a status change. This post goes deeper into the four operations you'll use in almost every form script: showing/hiding, enabling/disabling, setting required levels, and setting values — plus when you should reach for a Business Rule instead of writing code at all.

The Four Core Operations

Every field-level customization comes down to some combination of these, called on an attribute or its control via formContext:

var formContext = executionContext.getFormContext();
var control = formContext.getControl("new_discountreason");
var attribute = formContext.getAttribute("new_discountreason");

// 1. Show or hide a field
control.setVisible(true);

// 2. Enable or disable a field
control.setDisabled(false);

// 3. Set the required level
attribute.setRequiredLevel("required"); // "none", "recommended", or "required"

// 4. Set a value
attribute.setValue("Loyalty discount");

Business Rule vs. JavaScript: When to Use Which

Business Rules are the right first choice for simple, single-form or cross-form logic — they're faster to build, easier for other admins to maintain, and don't need a developer to change later. Reach for JavaScript when you need:

  • Logic that depends on more than the handful of conditions a Business Rule can cleanly express
  • Real-time reaction to typing, not just on change/save
  • Calls to the Web API, external services, or anything Business Rules simply can't do
  • Custom UI behavior — tab/section visibility, notifications, ribbon interaction

A good rule of thumb: if you can describe the logic in one sentence with "if this field equals X, then that field is required," use a Business Rule. If you're combining three or four conditions across multiple fields, JavaScript will be far easier to read and maintain.

A Practical Example: Multi-Field Conditional Logic

Here's a script that shows a discount reason field only when a discount is applied above a threshold, and only for certain customer types — logic that would get unwieldy fast in a Business Rule:

function onLoad(executionContext) {
    var formContext = executionContext.getFormContext();
        formContext.getAttribute("new_discountpercent").addOnChange(toggleDiscountReason);
            formContext.getAttribute("new_customertype").addOnChange(toggleDiscountReason);
                toggleDiscountReason(executionContext);
                }
                
                function toggleDiscountReason(executionContext) {
                    var formContext = executionContext.getFormContext();
                        var discount = formContext.getAttribute("new_discountpercent").getValue() || 0;
                            var customerType = formContext.getAttribute("new_customertype").getValue();
                                var reasonControl = formContext.getControl("new_discountreason");
                                    var reasonAttr = formContext.getAttribute("new_discountreason");
                                    
                                        var needsReason = discount > 10 && (customerType === 1 || customerType === 2);
                                        
                                            reasonControl.setVisible(needsReason);
                                                reasonAttr.setRequiredLevel(needsReason ? "required" : "none");
                                                    if (!needsReason) {
                                                            reasonAttr.setValue(null);
                                                                }
                                                                }

Section and Tab Visibility

The same show/hide pattern applies to whole sections and tabs, which is handy for progressively revealing parts of a long form:

var tab = formContext.ui.tabs.get("tab_shipping");
tab.setVisible(true);

var section = tab.sections.get("section_shippingdetails");
section.setVisible(true);

Common Pitfalls

  • setRequiredLevel takes a string, not a boolean. Use "none", "recommended", or "required" — not true/false.
  • Disabled fields still have values. setDisabled(true) stops editing but doesn't clear the value; clear it explicitly if that's what you need.
  • Hidden required fields block save. If a field is required but hidden, users get stuck. Always pair setVisible(false) with setRequiredLevel("none").
  • Re-run your logic on load, not just on change. If a record is opened with values already set, onChange handlers won't fire automatically — call your function once during OnLoad too, as in the example above.

What's Next

Next up: working with lookups, option sets, and subgrids from JavaScript — including how to filter a lookup's results based on another field on the form.

Getting Started with JavaScript in Dynamics 365 CRM Forms: A Step-by-Step Guide

If you've spent any time around the Dynamics 365 / Power Platform world lately, it's all Copilot, agents, and AI everywhere. But underneath most of that AI layer, the forms your users actually work in still run on plain old client-side JavaScript. It's still the most reliable way to control field behavior, run validations, and react to what a user is doing in real time — and it's a skill every CRM developer should be solid on. This is the first post in a short series on form scripting, working up from the basics to where it starts overlapping with Copilot Studio agents.

Why JavaScript Still Matters

Business Rules and Power Automate cover a lot of ground now, but there are still things only JavaScript does well: instant field-level reactions as a user types, custom validation logic that's too complex for a Business Rule, and dynamic UI changes (hiding sections, changing option sets, enabling/disabling controls) based on more than one condition at once. If you're building anything non-trivial on a form, you'll end up here eventually.

What You'll Need

  • A Dynamics 365 (model-driven app) environment with System Customizer or System Administrator access
  • A basic understanding of JavaScript (variables, functions, conditionals)
  • Browser developer tools (F12) for testing and debugging

Step 1: Create the JavaScript Web Resource

In the Power Apps maker portal, go to your solution and select New → More → Web resource. Choose type Script (JScript), upload or paste your .js file, and give it a clear naming convention, e.g. new_/forms/account_form.js. Save and publish.

Step 2: Add the Library to the Form

Open the form in the form editor, go to the Form Libraries section, and add the web resource you just created. This makes its functions available to the form's event handlers.

Step 3: Understand formContext Basics

Every modern form script receives an executionContext, from which you get the formContext — your entry point to everything on the form. Avoid the old Xrm.Page pattern; it's deprecated. Here's the core API you'll use constantly:

function onLoad(executionContext) {
    var formContext = executionContext.getFormContext();
    
        // Get an attribute (field)
            var statusAttr = formContext.getAttribute("statuscode");
            
                // Get a control (the UI element for that field)
                    var reasonControl = formContext.getControl("new_reasoncode");
                    
                        // Read and write values
                            var currentValue = statusAttr.getValue();
                                statusAttr.setValue(1);
                                }

Step 4: Register the OnLoad and OnChange Events

In the form editor, select the form's properties (or a specific field's event handlers) and register your function against the OnLoad or OnChange event. Here's a working example that shows/hides and requires a reason field based on status:

function onLoad(executionContext) {
    var formContext = executionContext.getFormContext();
        var statusAttr = formContext.getAttribute("statuscode");
            if (statusAttr) {
                    statusAttr.addOnChange(onStatusChange);
                            onStatusChange(executionContext); // run once on load too
                                }
                                }
                                
                                function onStatusChange(executionContext) {
                                    var formContext = executionContext.getFormContext();
                                        var status = formContext.getAttribute("statuscode").getValue();
                                            var reasonControl = formContext.getControl("new_reasoncode");
                                                var reasonAttr = formContext.getAttribute("new_reasoncode");
                                                
                                                    if (status === 2) {
                                                            reasonControl.setVisible(true);
                                                                    reasonAttr.setRequiredLevel("required");
                                                                        } else {
                                                                                reasonControl.setVisible(false);
                                                                                        reasonAttr.setRequiredLevel("none");
                                                                                            }
                                                                                            }

Step 5: Add Save Validation

Register a function on the form's OnSave event to block a save when something's wrong — useful for checks that are too specific for a Business Rule:

function onSave(executionContext) {
    var formContext = executionContext.getFormContext();
        var emailAttr = formContext.getAttribute("emailaddress1");
            var email = emailAttr ? emailAttr.getValue() : null;
            
                if (email && email.indexOf("@") === -1) {
                        executionContext.getEventArgs().preventDefault();
                                formContext.ui.setFormNotification(
                                            "Please enter a valid email address before saving.",
                                                        "ERROR",
                                                                    "email_validation"
                                                                            );
                                                                                } else {
                                                                                        formContext.ui.clearFormNotification("email_validation");
                                                                                            }
                                                                                            }

Common Pitfalls

  • Forgetting to pass executionContext. When registering an event handler in the form editor, make sure "Pass execution context as first parameter" is checked.
  • Library load order. If one script depends on functions in another, the dependent library needs to be added after it in the Form Libraries list.
  • Null reference errors. Always check an attribute or control exists before calling methods on it — fields aren't always on every form.
  • Testing only in the maker portal. Always test in the actual app the field workers use; form customizations can differ across apps.

What's Next in This Series

This is post one of five on form scripting. Coming up:

  • Field-level logic in depth: show/hide, enable/disable, required levels, and when to use a Business Rule instead
  • Working with lookups, option sets, and subgrids from JavaScript
  • Calling the Dataverse Web API from form scripts to read and update related records
  • From script to agent: wiring a Copilot Studio agent action into a JavaScript-driven ribbon button

Form scripting isn't going away just because agents are getting smarter — if anything, knowing both is what makes a CRM developer valuable right now. Next post picks up with field-level logic in more depth.

What your Copilot agent can actually see: Dataverse security when the agent is asking

What your Copilot agent can actually see

Nobody worried much about oversharing in CRM, because advanced find was tedious and nobody went looking. An agent removes that. Everything a user technically has access to is now one plain-English question away.


Your security model didn't change. What changed is that it's finally being exercised.

Identity is the whole design

Two options, and they behave completely differently.

User authentication. The signed-in user's identity flows through to Dataverse, which applies your security roles and record-level permissions as normal. The same prompt returns different answers for different people, which is exactly right.

Agent author authentication. A fixed credential does the work regardless of who asked. Microsoft scopes this to implicit or low-risk access — a weather lookup, a public phone number. If you reach for it to make something work, you've just built a bypass around your own security model.

Default to user auth. Fall back only for a genuine service-account case, like reading a shared mailbox.

Cumulative permissions get expensive

Dataverse adds up every grant from every role and every team, then checks. There's no deny.

That was survivable when access was theoretical. Now an agent will happily aggregate across everything the user can reach and summarise it in one answer. The stray owner team somebody created three years ago to solve a visibility request stops being clutter and starts being a disclosure.

Before you switch anything on: pull the role and team membership for a sample of users and check what they can actually see, not what they're supposed to see.


What the model can't protect

  • Column security profiles — salary, ID numbers, bank details. If the field is readable, the agent can read it and put it in a sentence.
  • Record sharing — invisible at design time, unauditable in bulk, and it grants exactly the access the agent will use.
  • Notes and attachments — usually wide open, frequently containing things people would never put in a proper field.

The paths that aren't in the security model at all

This is where agent projects leak. Most failures aren't a misconfigured table privilege:

  • Which connection the agent's actions run under, and whose permissions that carries
  • Whether a flow behind the agent runs as a service account with more rights than the user
  • App sharing and DLP policy governing which connectors the agent can reach
  • Any autonomous agent, which has no signed-in user at all — that identity is the ceiling on everything it can do

Give service identities a purpose-built role scoped to the specific tables they need. Not System Administrator because it was faster.

Treat retrieved content as untrusted

Records and documents are input, and input can carry instructions. A case description written by an external customer is not a safe place to take direction from.

The design rule: retrieved content informs an answer, it never authorises an action. Anything privileged runs on validated parameters through a flow or plugin, where the rules are enforced server-side and a model can't talk its way past them.

Where to start

  • Confirm every action is using user authentication unless there's a written reason not to
  • Audit team memberships and delete the ones nobody can explain
  • Column-secure the fields that would be a problem in a chat window
  • Check which identity your flows and connections actually run as
  • Test the agent as three different users, not as yourself

None of this is AI work. It's the security model you already had, finally being read out loud.

Related: Copilot in Dynamics 365 — what it actually does

Thanks for reading this article. Hope this Article will help you. Cheers!!!

#Dynamics365 #PowerPlatform #MicrosoftCopilot #DynamicsCRM #CopilotStudio #Dataverse #AIDeveloper #DynamicsAIEngineer #HireAIEngineer #AIEngineer #D365Consultant #CRMDeveloper #AIAgents #MSDyn365

Copilot Studio agent ALM: getting from dev to production


Copilot Studio gets you from idea to working agent very quickly. That's the whole appeal. It also means an agent can be in front of real users before anyone's thought about how to change it safely.

Here's what I'd have in place before that happens.

Work inside a solution from the start

Agents are proper solution components, so the agent, its topics, knowledge configuration, flows, environment variables and connection references all move together as one package.

  • Create a custom publisher with your own prefix before you build anything — changing it later means recreating components
  • Keep one solution unless you genuinely need to deploy parts independently
  • Build unmanaged in dev, export managed to test and production
  • Push changes one direction only: fix in dev and redeploy, never patch production

Make anything environment-specific a variable

In dev it all just works, which is exactly why nobody notices it's hardcoded.

  • SharePoint site URLs used as knowledge sources
  • External API endpoints and base URLs
  • Notification and system email addresses
  • API keys and client secrets — use the Secret type so the value lives in Azure Key Vault, not your solution

There's a trap here worth knowing. A default value you set in dev travels with the solution. If nobody sets a proper value in the target, it quietly falls back to the dev one. That's how a production agent ends up reading your dev SharePoint site while looking perfectly healthy.

Use connection references, not connections

Credentials then bind per environment and you can run as a different account in prod. Which means deciding who the agent actually runs as — a dedicated service account or application user, with roles scoped to what it genuinely needs. Not a maker's personal account. You'll find out why the week they leave.

Promote through Pipelines

The useful part is what it checks before letting you deploy:

  • Every environment variable has a value in the target
  • Every connection reference resolves

When it fails on missing dependencies, it's usually the three dots next to the agent → Advanced → Add required objects, then try again.

The bit no tooling fixes

You can diff a plugin. You can read a pull request. You can't meaningfully diff an agent, because the change lives in instructions and topic logic — so "what changed since Tuesday" has no honest answer.

The only workaround I've found:

  • Keep twenty or so questions with known good answers
  • Cover your main topics plus whatever broke before
  • Run the lot by hand after every deploy

It's crude. It's also the only regression test you've got.

None of this is exciting work. It's just the difference between shipping version 2 and being scared to touch version 1.

Related: Copilot in Dynamics 365 — what it actually does


Thanks for reading this article. Hope this Article will help you. Cheers!!!

#Dynamics365 #PowerPlatform #MicrosoftCopilot #DynamicsCRM #CopilotStudio #Dataverse #AIDeveloper #DynamicsAIEngineer #HireAIEngineer #AIEngineer #D365Consultant #CRMDeveloper #AIAgents #MSDyn365

Friday, 11 September 2026

Copilot and SharePoint: fix the library first

Grounding a Copilot agent on SharePoint: what actually breaks

Pointing an agent at a SharePoint site takes about ten minutes. Pick the site, save, ask a question, get an answer. Everyone's impressed.

Then you ask something you already know the answer to, and it tells you something that was true in 2021.

I've done SharePoint work since long before any of this arrived, and what strikes me is how the old problems have suddenly become expensive. Nobody much cared that the expenses policy lived in six places. Now it's why your agent gives people the wrong expenses answer.


Versions. The agent finds one copy and answers from it, with complete confidence either way. It has no idea FINAL_v3_updated_new is the real one. Pick the authoritative copy, archive the rest, ground only on the place you trust.

Scanned PDFs. If there's no text layer, the file doesn't exist as far as the agent is concerned. That usually includes your signed contracts — the documents people most want to ask about. Check a sample before promising anything.

Permissions. The agent honours SharePoint permissions, which is what you want. The catch is the reverse: anything people could already see is now genuinely findable. Search used to be bad enough that oversharing stayed invisible. That gap is gone. Run a permissions review before you switch anything on, not after someone surfaces the salary spreadsheet in a chat window.

Metadata and file names. Empty columns are context the model doesn't get. You don't need a taxonomy project — a Status column with Draft, Current and Superseded, actually maintained, does most of the work. Same with drafts sitting in the same folder as approved documents. Separate them, or ground only on the finished material.

What this means for your estimate. Roughly two days on the agent, a few weeks on the library. Say that during scoping rather than discovering it halfway through.

It usually lands better than you'd think. Most organisations know their documents are a mess — they've just never had a reason good enough to fix it. This is the first time anyone's been willing to pay for it.


Thanks for reading this article. Hope this Article will help you. Cheers!!!

#Dynamics365 #PowerPlatform #MicrosoftCopilot #DynamicsCRM #CopilotStudio #Dataverse #AIDeveloper #DynamicsAIEngineer #HireAIEngineer #AIEngineer #D365Consultant #CRMDeveloper #AIAgents #MSDyn365


Thursday, 10 September 2026

Before you switch Copilot on, spend a week in your data. Here's what I'd check.

If you're about to switch Copilot on, spend a week in your data first.

None of what follows is exciting. That's rather the point.


Start with your description fields. Copilot can only summarise what's actually there, so if half your cases have a subject line and nothing else, people get summaries that tell them nothing and stop opening them by week two.

Then your duplicates. The model has no idea which account record is the real one. It'll pick one anyway and sound completely sure about it.

Have a look at your column names too. Anything still called new_field1 needs renaming, or at least a description, because that text is what the model reads when it's working out what a field means.

Same with the ten years of closed records nobody ever archived. Leave that lot in and you'll get answers pulled from deals that died in 2018.

Watch for free text where a choice column should be. "In Progress", "in progress" and "InProg" are three different things to anything reading them.

And read a sample of your notes and attachments before you make them a knowledge source. A surprising amount of what's in there is people venting about customers.

Nobody signs off a budget for any of this. It isn't the AI project anyone pictured. But it's most of the difference between a pilot that gets extended and one that dies quietly in month three.

What's the worst data you've had to switch AI on top of?

Related: Copilot in Dynamics 365 — what it actually does

Thanks for reading this article. Hope this Article will help you. Cheers!!!

#Dynamics365 #PowerPlatform #MicrosoftCopilot #Dataverse #DataQuality #AIDeveloper

Sunday, 6 September 2026

Is the D365 developer job changing? (plugins vs agents)

Someone asked me last week if plugin development is dead.

It isn't. But the shape of the work has definitely moved.

A few years ago most of my time went into plugins, JavaScript, custom workflow activities. Making the platform do things it wouldn't do on its own. A fair chunk of that is now either configuration, or an agent action, or Copilot handles it well enough that nobody asks for it any more.



What's replaced it isn't easier, just different. Data modelling matters more than it ever did, because agents inherit whatever mess you left in the schema. Security is back at the centre of every conversation. And a surprising amount of my week goes on explaining what the AI can't do, which is a strange skill to have picked up.


If you're worried about it, here's the thing I'd hold on to: deterministic logic still belongs in plugins. Anything that has to run the same way every single time isn't going anywhere near a model. Agents are for the fuzzy edges. Knowing which side a requirement falls on is the actual job now.

Anyone else seeing their week shift like this?

Related: Copilot in Dynamics 365 — what it actually does

Thanks for reading this article. Hope this Article will help you. Cheers!!!

#Dynamics365 #PowerPlatform #MicrosoftCopilot #DynamicsCRM #CopilotStudio #Dataverse #AIDeveloper #DynamicsAIEngineer #HireAIEngineer #AIEngineer #D365Consultant #CRMDeveloper #AIAgents #MSDyn365


Testing a Copilot agent: evaluation, not "it seemed to work"

Given input, expect output works for a plugin. Agent responses depend on prompt, retrieval and context interpretation, so you measure respon...