·8 min read·Rishi

Building Custom Copilot Plugins for D365 Sales: Your First Action Step by Step

Building Custom Copilot Plugins for D365 Sales: Your First Action Step by Step

A sales rep opens Copilot in Dynamics 365 Sales and types: "Summarize the open opportunities for Contoso." Copilot thinks for a moment, then returns a formatted summary — total pipeline value, number of open opportunities, the largest deal, and the next close date. The rep did not navigate to the account, did not open a view, did not build a report. They asked a question and got an answer.

That summary did not come from a built-in Copilot feature. It came from a custom plugin you built. And building it is more accessible than you might think.

What Copilot Plugins Are

Copilot in D365 Sales uses a plugin architecture to extend its capabilities beyond the out-of-box features. When a user asks Copilot something, the system:

  1. Parses the natural language to determine intent
  2. Matches the intent to a registered plugin action
  3. Calls the plugin with extracted parameters
  4. Returns the result formatted as a Copilot response

Your custom plugin sits at step 3. You define a Dataverse custom API that does the work, describe it so Copilot knows when to call it, and register it as a Copilot action. Copilot handles the natural language understanding and parameter extraction.

The Architecture

User prompt → Copilot orchestrator → Intent matching
                                           ↓
                                    Plugin descriptor
                                    (name, description,
                                     parameters)
                                           ↓
                                    Dataverse Custom API
                                           ↓
                                    Your plugin code
                                    (C# or low-code)
                                           ↓
                                    Formatted response
                                    → Copilot → User

The key insight: Copilot decides when to call your plugin based on the description you provide. A vague description means Copilot will not match it correctly. A precise description means it fires exactly when it should.

Step 1: Define the Custom API

We will build the "summarize open opportunities" action. First, create a custom API in your Dataverse solution.

In the solution explorer, create a new Custom API:

PropertyValue
Unique Namerishi_SummarizeAccountOpportunities
NameSummarize Account Opportunities
Display NameSummarize Account Opportunities
DescriptionRetrieves a summary of all open opportunities for a given account, including total value, count, largest deal, and next close date
Binding TypeGlobal
Is FunctionNo (this is an action, not a function)
Allowed Custom Processing Step TypeSync Only

Define the Request Parameter

Add one request parameter:

PropertyValue
Unique NameAccountName
NameAccountName
TypeString
Is OptionalNo
DescriptionThe name of the account to summarize opportunities for

Define the Response Property

Add one response property:

PropertyValue
Unique NameSummary
NameSummary
TypeString
DescriptionA formatted text summary of the account's open opportunities

Step 2: Write the Plugin Code

Create a C# plugin that executes when Copilot calls your custom API.

using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
using System;
using System.Text;

public class SummarizeAccountOpportunities : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        var context = (IPluginExecutionContext)serviceProvider
            .GetService(typeof(IPluginExecutionContext));
        var serviceFactory = (IOrganizationServiceFactory)serviceProvider
            .GetService(typeof(IOrganizationServiceFactory));
        var service = serviceFactory.CreateOrganizationService(context.UserId);

        // Extract the account name from the request
        string accountName = context.InputParameters["AccountName"] as string;

        if (string.IsNullOrWhiteSpace(accountName))
        {
            context.OutputParameters["Summary"] =
                "I need an account name to look up opportunities.";
            return;
        }

        // Find the account
        var accountQuery = new QueryExpression("account")
        {
            ColumnSet = new ColumnSet("name", "accountid"),
            Criteria = new FilterExpression
            {
                Conditions =
                {
                    new ConditionExpression(
                        "name", ConditionOperator.Like,
                        $"%{accountName}%")
                }
            },
            TopCount = 1
        };

        var accounts = service.RetrieveMultiple(accountQuery);

        if (accounts.Entities.Count == 0)
        {
            context.OutputParameters["Summary"] =
                $"No account found matching '{accountName}'.";
            return;
        }

        var account = accounts.Entities[0];
        var accountId = account.Id;
        var resolvedName = account.GetAttributeValue<string>("name");

        // Query open opportunities for this account
        var oppQuery = new QueryExpression("opportunity")
        {
            ColumnSet = new ColumnSet(
                "name", "estimatedvalue", "estimatedclosedate",
                "statuscode"),
            Criteria = new FilterExpression
            {
                Conditions =
                {
                    new ConditionExpression(
                        "parentaccountid", ConditionOperator.Equal,
                        accountId),
                    new ConditionExpression(
                        "statecode", ConditionOperator.Equal, 0) // Open
                }
            }
        };

        var opportunities = service.RetrieveMultiple(oppQuery);

        if (opportunities.Entities.Count == 0)
        {
            context.OutputParameters["Summary"] =
                $"{resolvedName} has no open opportunities.";
            return;
        }

        // Build the summary
        decimal totalValue = 0;
        decimal largestDeal = 0;
        string largestDealName = "";
        DateTime? nextCloseDate = null;

        foreach (var opp in opportunities.Entities)
        {
            var value = opp.GetAttributeValue<Money>("estimatedvalue");
            decimal amount = value?.Value ?? 0;
            totalValue += amount;

            if (amount > largestDeal)
            {
                largestDeal = amount;
                largestDealName = opp.GetAttributeValue<string>("name");
            }

            var closeDate = opp
                .GetAttributeValue<DateTime?>("estimatedclosedate");
            if (closeDate.HasValue
                && (!nextCloseDate.HasValue
                    || closeDate.Value < nextCloseDate.Value))
            {
                nextCloseDate = closeDate;
            }
        }

        var sb = new StringBuilder();
        sb.AppendLine($"**{resolvedName}** — Open Opportunity Summary:");
        sb.AppendLine();
        sb.AppendLine(
            $"- **Count:** {opportunities.Entities.Count} open opportunities");
        sb.AppendLine(
            $"- **Total pipeline:** {totalValue:C0}");
        sb.AppendLine(
            $"- **Largest deal:** {largestDealName} ({largestDeal:C0})");
        if (nextCloseDate.HasValue)
            sb.AppendLine(
                $"- **Next close date:** {nextCloseDate.Value:MMM dd, yyyy}");

        context.OutputParameters["Summary"] = sb.ToString();
    }
}

Key Design Decisions

  • Fuzzy matching on account name — Copilot might pass "Contoso" when the actual name is "Contoso Ltd." Using Like with wildcards handles this
  • Markdown formatting — Copilot renders markdown, so bold and bullet lists work in the response
  • Graceful degradation — if no account is found or no opportunities exist, return a useful message instead of an error

Step 3: Register the Plugin

Register the plugin assembly using the Plugin Registration Tool:

  1. Register the assembly containing SummarizeAccountOpportunities
  2. Register a step on message rishi_SummarizeAccountOpportunities (your custom API name)
  3. Set the step to run synchronously in the main operation stage (stage 30)

There is no entity binding — custom APIs use their unique name as the message.

Step 4: Register as a Copilot Action

This is where your plugin becomes visible to Copilot.

  1. In the Power Platform admin center, navigate to your environment
  2. Go to Copilot > Actions (or use the maker portal under Copilot Studio)
  3. Create a new Connector action pointing to your custom API
  4. Fill in the action details:
FieldValue
NameSummarize Account Opportunities
DescriptionCritical: "When the user asks for a summary of opportunities, pipeline, or deals for a specific account or company, call this action with the account name"
Parameter: AccountNameDescription: "The name of the account, company, or customer the user is asking about"

The description is everything. Copilot uses it to decide when to invoke your action. Write it as if you are instructing a coworker: "When someone asks about X, do Y with Z."

Step 5: Test It

In D365 Sales, open Copilot and try these prompts:

  • "Summarize the open opportunities for Contoso"
  • "What's in the pipeline for Adventure Works?"
  • "How many deals does Fabrikam have open?"

Each of these should trigger your plugin. If Copilot does not match your action, revisit the description — it probably needs to be more explicit about the trigger phrases.

Gotchas That Will Cost You Time

1. Security Context

Your plugin runs in the context of the calling user. If the sales rep does not have read access to certain opportunities (due to business unit security or team ownership), the summary will not include those records. This is correct behavior — but test with a realistic user, not a system admin.

2. Description Quality Determines Hit Rate

If your description says "Summarizes opportunities," Copilot might not match it when a user asks "What deals does Contoso have open?" Include synonyms and variations in the description:

"When the user asks for a summary, overview, or list of opportunities, deals, pipeline, or open sales for a specific account, company, customer, or organization..."

3. Response Length Limits

Copilot truncates long responses. If an account has 200 open opportunities, do not list them all. Aggregate, summarize, and highlight the top items. Keep responses under 1,000 characters.

4. Timeout Constraints

Copilot actions have a timeout (typically 30 seconds). If your plugin queries large datasets, it needs to be fast. Use indexed fields in your queries, limit the column set, and consider caching expensive lookups.

5. Testing in Development Environments

Copilot features require specific licenses and environment configurations. Your dev sandbox may not have Copilot enabled. Test in a dedicated Copilot-enabled environment, and budget time for this in your sprint planning.

Beyond the Basics

Once you have one action working, the pattern scales:

  • "What's the last activity for this contact?" — query activity records, return the most recent
  • "Create a follow-up task for next week" — accept a description and due date, create a task record
  • "Which opportunities are closing this month?" — query by estimated close date range

Each follows the same pattern: Custom API, plugin code, Copilot action registration. The hard part is not the code — it is writing descriptions that make Copilot reliably match the right action to the right prompt.

Start with one action. Get the description right. Then scale.

Comments

No comments yet. Be the first!