This document describes the technical architecture of the D365 Copilot Toolbox core solution for integrating Microsoft Copilot Studio agents into D365 Finance & Operations.
The Copilot Toolbox is designed to enable multi-agent workflows in D365 F&O. This architecture focuses on the foundational Copilot Studio integration, which provides the framework for embedding agents, routing to different agents based on application areas, and managing context flow between D365 and AI agents.
graph TB
subgraph D365["D365 Finance & Operations (Browser)"]
subgraph Server["X++ Server-Side"]
Control["COTXCopilotHostControl<br/>(FormTemplateControl)"]
FormCtx["COTXCopilotHostFormContext"]
GlobalCtx["COTXCopilotHostGlobalContext"]
end
subgraph Browser["Browser-Side (HTML/JS/CSS)"]
JS["COTXCopilotHostControl.js"]
MSAL["MSAL.js<br/>(Auth)"]
WebChat["WebChat<br/>(UI)"]
SDK["Copilot Studio SDK<br/>(DirectLine connection)"]
end
end
subgraph Copilot["Microsoft Copilot Studio<br/>(Dataverse / Power Platform)"]
Agent["Agent processes messages,<br/>executes tools, returns responses"]
end
Control --> JS
MSAL --> SDK
WebChat --> SDK
SDK --> Agent
| Class | Responsibility |
|---|---|
COTXCopilotHostControl |
Main extensible form control. Reads agent configuration from the database, initializes form properties, and passes them to the browser-side JS. Handles incoming agent responses via RaiseAgentResponse command. |
COTXCopilotHostControlBuild |
Design-time companion class. Exposes Application Area and Context Scope properties in the Visual Studio form designer. |
COTXCopilotHostFormContext |
Tracks a single form's context: data area, form caption, menu item name, root data source table/record, and natural key/value. Fires onFormContextChange when the active record changes. |
COTXCopilotHostGlobalContext |
Singleton that subscribes to Info.onActivate; when the user navigates between root-navigable forms, it constructs a new COTXCopilotHostFormContext and propagates changes to the side panel control. |
| File | Responsibility |
|---|---|
COTXCopilotHostControl.html |
Loads MSAL Browser (v5, locally bundled), WebChat 4.18.0 (CDN), and the main JS file. Contains the root <div> for the control. |
COTXCopilotHostControl.js |
Orchestrates the entire browser-side flow: MSAL token acquisition (with per-client instance caching and multi-tenant account selection), Copilot Studio SDK connection, tab manager (create / close / rename / switch tabs), chat layout creation (tab bar + per-tab containers), WebChat rendering, chat restart lifecycle, context injection middleware, tool call card rendering, thought bubble injection, and D365 extensible control registration. |
COTXCopilotHostControl.css |
Styles the chat interface — tab bar (buttons, close, rename input, add/restart), per-tab chat containers, bubble appearance, tables, lists, scrollbars, and headings — to match a modern Copilot aesthetic. |
COTXMsalRedirectBridge.html |
MSAL v5 redirect bridge HTML container for COOP-compatible popup/iframe authentication. |
COTXMsalRedirectBridge.js |
Companion script for the redirect bridge — handles token redirect responses in popup windows. |
| Table | Purpose |
|---|---|
COTXCopilotAgentParameters |
Stores per-agent configuration: Entra ID credentials, Dataverse connection details, context and display preferences. Cross-company (shared). |
COTXCopilotAgentApplicationAreas |
Maps COTXCopilotAgentApplicationArea enum values to COTXCopilotAgentParameters records. Enables multi-agent routing by application area. |
flowchart TD
A["Form.init()"] --> B["FormRun creates COTXCopilotHostControl"]
B --> C["new(): Registers all FormProperty bindings"]
C --> D["applyBuild(): Reads design-time properties"]
D --> E["initializeControl(applicationArea)"]
E --> F["Reads COTXCopilotAgentParameters for the area"]
E --> G["Sets connection properties (AppClientId, TenantId, etc.)"]
E --> H["Sets user properties (UserId, UserName)"]
E --> I["Subscribes to context changes"]
I --> J["Local scope → COTXCopilotHostFormContext"]
I --> K["Global scope → COTXCopilotHostGlobalContext"]
flowchart TD
A["JS init()"] --> B["waitForDependencies() — polls up to 60 frames"]
B --> P["readControlParameters(data)"]
P --> L["ensureChatLayout(element) — tab bar + container area"]
L --> LR["Wire restart button → restartChat()"]
L --> LA["Wire add-tab button → createTab()"]
L --> T["createTab() — first conversation tab"]
T --> IW["initializeWebChat(container, data, params, tab)"]
IW --> C["acquireToken(appClientId, tenantId)"]
C --> C1["MSAL instance cached per clientId|tenantId"]
C1 --> C2["Try silent — tenant-matched account → token"]
C1 --> C3["Fallback: popup → access token"]
C --> D["createCopilotConnection(token, envId, agentId)"]
D --> D1["CopilotStudioWebChat.createConnection() → DirectLine"]
D --> E["WebChat.renderWebChat(directLine, store, tab.container)"]
E --> E1["Store middleware intercepts"]
E1 --> E2["Outgoing messages: injects ERP context"]
E1 --> E3["Incoming messages: captures agent responses"]
E1 --> E4["Events: renders tool call Adaptive Cards"]
E1 --> E5["Thoughts: injects chain-of-thought bubbles"]
E --> O["observePendingMessages() — only active tab"]
flowchart TD
ADD["+ Add Tab button"] --> CT["createTab(data, params, self)"]
CT --> CHK{"tabOrder.length < maxTabs (8)?"}
CHK -- No --> WARN["Console warning — limit reached"]
CHK -- Yes --> MKID["Generate tab ID + name"]
MKID --> DOM["Create tab button + chat container"]
DOM --> ENT["Attach Enter key listener (stopPropagation)"]
DOM --> SW["switchToTab(tabId) — show container, highlight button"]
SW --> IW2["initializeWebChat() — token → connection → render"]
CLOSE["× Close Tab button"] --> CL["closeTabById(tabId)"]
CL --> DISP["Dispose subscription, unmount React, end DirectLine"]
DISP --> RM["Remove DOM + tab manager entry"]
RM --> NEXT["switchToTab(nearest remaining tab)"]
RESTART["↻ Restart button"] --> RC["restartChat(data, self)"]
RC --> TEAR["Dispose subscription + end DirectLine + unmount React"]
TEAR --> REINIT["initializeWebChat() — fresh session in same tab"]
RENAME["Double-click tab label"] --> INP["Inline rename input"]
INP --> BLUR["On blur / Enter → update tab.name"]
sequenceDiagram
participant User
participant Info
participant GlobalCtx as COTXCopilotHostGlobalContext
participant FormCtx as COTXCopilotHostFormContext
participant Control as COTXCopilotHostControl
participant JS as Browser JS
User->>Info: Navigates to a new form
Info->>GlobalCtx: onActivate fires
GlobalCtx->>GlobalCtx: handleFormActivation(formRun)
GlobalCtx->>FormCtx: Creates new COTXCopilotHostFormContext
FormCtx->>FormCtx: Subscribes to root data source OnActivated
FormCtx->>GlobalCtx: Fires onFormContextChange
GlobalCtx->>Control: formContextChange()
Control->>Control: Updates FormProperty bindings
Control->>JS: JS reads updated properties
JS->>JS: Next chat message includes new context
sequenceDiagram
participant Form
participant Control as COTXCopilotHostControl
participant FormCtx as COTXCopilotHostFormContext
participant DS as Root DataSource
Form->>Control: initializeControl() with Local scope
Control->>FormCtx: Creates COTXCopilotHostFormContext for this form
FormCtx->>DS: Subscribes to OnActivated
DS->>FormCtx: User changes record
FormCtx->>Control: formContextChange fires
Note over Control: Same flow as global, but scoped to one form
sequenceDiagram
participant Agent as Copilot Studio Agent
participant WebChat as WebChat Store (per tab)
participant JS as Browser JS
participant Control as COTXCopilotHostControl (X++)
participant Form as Form Event Handlers
Agent->>WebChat: Sends reply
WebChat->>JS: Captures incoming bot message
alt tabState.waitingForBotReply (X++ initiated on active tab)
JS->>Control: Calls RaiseAgentResponse command
Control->>Control: Sets parmAgentResponse property
Control->>Form: Fires onAgentResponse delegate
Form->>Form: Event handlers react
end
Note: Only the active tab processes
PendingMessagefrom X++ and firesonAgentResponse. Inactive tabs ignore pending messages.
The ERP context is injected into the channelData.context of every outgoing WebChat message:
{
"channelData": {
"context": {
"userLanguage": "en-us",
"userTimeZone": "GMT Standard Time",
"callingMethod": "",
"legalEntity": "USMF",
"currentUser": "Admin",
"currentForm": "All Sales Orders",
"currentMenuItem": "Sales order",
"formMode": "",
"currentRecord": {
"tableName": "Sales order",
"naturalKey": "Sales order",
"naturalValue": "SO-000123"
}
}
}
}| Decision | Rationale |
|---|---|
| Browser-side MSAL | No server-side secrets needed; leverages the user's existing Entra ID session. Popup fallback ensures first-time auth works. |
| FormTemplateControl | D365's extensible control pattern provides property binding, build-time designer support, and lifecycle hooks. |
| Global singleton for context | A single COTXCopilotHostGlobalContext instance subscribes once to Info.onActivate, avoiding redundant subscriptions. |
| Application area routing | Lookup table pattern allows multiple agents, with Fallback as a catch-all, extensible via enum extensions. |
| Custom form pattern for side panel | Aside pane forms require the Custom pattern and setDisplayTarget(AsidePane) before super() — this is per Microsoft guidance. |
| Keep connection alive option | When enabled, dispose() skips terminating the Direct Line connection. This avoids the latency of re-authenticating and reconnecting when the form is re-opened quickly. The flag is read once after full initialization — it is not re-evaluated at dispose time. |
| MSAL instance caching | A module-scoped _msalCache object stores PublicClientApplication instances keyed by `clientId |
| Multi-tenant account selection | When multiple accounts exist in the MSAL session-storage cache (e.g. home-tenant + cross-tenant), acquireToken filters by tenantId to pick the correct identity rather than blindly using accounts[0]. This is critical for multi-tenant agent configurations. |
| Tab manager per control instance | Each COTXCopilotHostControl instance owns its own _tabManager object (tabs, activeTabId, tabOrder, maxTabs). This avoids collisions when multiple controls exist on the same page (e.g. side panel + embedded form control). |
| Per-tab state isolation | waitingForBotReply and toolCalls are scoped to each tab's state object, not module-level. This prevents cross-tab interference when multiple conversations are active. |
| MSAL v5 redirect bridge | MSAL v5 requires a redirect bridge page for COOP-compatible popup authentication. The bridge (COTXMsalRedirectBridge.html) is bundled as an AxResource and handles token redirect responses in a separate window context. |
Vendor libraries are bundled locally as AxResources. They are not loaded from external CDNs at runtime. Use
Scripts/Update-VendorLibs.ps1to download or update them. SeeScripts/vendor-libs.jsonfor the manifest.
The control depends on three npm packages, downloaded at build/development time and shipped as D365 AxResource items:
| Library | npm Package | AxResource | Purpose |
|---|---|---|---|
| MSAL Browser | @azure/msal-browser |
COTXMsalBrowser_JS |
Browser-side OAuth2/MSAL token acquisition |
| MSAL Redirect Bridge | @azure/msal-browser |
COTXMsalRedirectBridge_JS |
COOP-compatible auth in popups/iframes (MSAL v5) |
| WebChat | botframework-webchat |
COTXWebChat_JS |
Bot Framework WebChat UI rendering |
| Copilot Studio Client | @microsoft/agents-copilotstudio-client |
COTXCopilotStudioClient_MJS |
Copilot Studio DirectLine connection SDK (ESM) |
Additionally, a redirect bridge HTML page is bundled:
| AxResource | File | Purpose |
|---|---|---|
COTXMsalRedirectBridge_HTML |
COTXMsalRedirectBridge.html |
HTML container for the MSAL v5 redirect bridge |
Vendor libraries are managed via:
Scripts/vendor-libs.json— manifest listing each package, version, source file path, and output file nameScripts/Update-VendorLibs.ps1— PowerShell script that reads the manifest, downloads packages from npm, and extracts the required files into the AxResource content folders
The script supports three modes:
| Mode | Command | Description |
|---|---|---|
| Download | Update-VendorLibs.ps1 |
Downloads missing vendor files (skips existing) |
| Force download | Update-VendorLibs.ps1 -Force |
Re-downloads all vendor files |
| Check for updates | Update-VendorLibs.ps1 -CheckForUpdates |
Queries npm for newer versions without downloading |
| Check + bump manifest | Update-VendorLibs.ps1 -CheckForUpdates -UpdateManifest |
Queries npm and writes new versions into vendor-libs.json |
A GitHub Actions workflow (.github/workflows/update-vendor-libs.yml) runs weekly on Mondays at 08:00 UTC. It:
- Checks the npm registry for newer versions of each vendor library
- Updates
Scripts/vendor-libs.jsonif newer versions are found - Downloads the updated files
- Opens a pull request with the changes for review
The workflow can also be triggered manually via workflow_dispatch, with an option to force re-download all libraries.