How to Create a Custom Plugin for DynamicsSuite
DynamicsSuite is designed with an extensible, modular architecture inspired by XrmToolBox, but built entirely with Python and Streamlit. You can develop, test, and distribute custom tools with zero administrative privileges, automatic Microsoft Entra ID authentication, and instant hot-reloading.
1 Architecture Overview
Traditional Dynamics 365 developer desktop utilities often require Windows-only runtimes, machine administrator privileges to register assemblies, and complex build steps.
DynamicsSuite solves this by providing:
- Cross-Platform Runtime: Runs natively on Windows, macOS, and Linux inside user space.
- Streamlit UI Framework: Build dashboards, grids, inspectors, and charts using pure Python without writing frontend boilerplate.
- Centralized Authentication: The core shell manages Entra ID (MSAL) authentication, secure memory token caching, and tenant URLs. Plugins never handle client secrets, password prompts, or OAuth redirect loops.
- Dynamic Hot-Reloading: Any plugin placed into the
plugins/folder is automatically discovered at boot or when refreshing the Plugin Manager.
ctx.auth.get_token(ctx.org_url).
2 Plugin Anatomy & Folder Structure
Each plugin lives in its own subdirectory inside the plugins/ folder. The directory name acts as the plugin's unique identifier (plugin_id).
DynamicsSuite/
└── plugins/
└── my_table_counter/ <-- Unique plugin_id folder
├── plugin.py <-- REQUIRED: Entry point subclassing PluginBase
├── README.md <-- RECOMMENDED: Markdown documentation for portal & in-app guide
├── requirements.txt <-- OPTIONAL: Pip packages needed by your tool
├── client.py <-- RECOMMENDED: API client & Dataverse logic
└── ui.py <-- RECOMMENDED: Streamlit view functions
A minimal plugin requires only plugins/<plugin_id>/plugin.py. Supplying a README.md automatically generates the interactive documentation modal on the Web Portal and the in-app guide in the desktop Plugin Manager!
3 Step 1: Subclassing PluginBase
In your plugin.py, import and subclass core.plugin_base.PluginBase. Define metadata attributes at the class level and implement the abstract render(self, ctx) method:
import streamlit as st
from core.plugin_base import PluginBase
from core.context import AppContext
class TableCounterPlugin(PluginBase):
# Metadata displayed in Plugin Manager, Web Portal & Sidebar
name: str = "Dataverse Table Counter"
icon: str = "📊"
description: str = "Quickly inspect and count records across selected Dataverse entities."
version: str = "1.0.0"
author: str = "Your Name or Team"
tags: list[str] = ["Dataverse", "Metadata", "Utilities"]
# Optional support and community repository links
support_email: str = "[email protected]" # Defaults to [email protected]
repo_url: str = "https://github.com/your-username/my-dynamics-plugin"
issues_url: str = "https://github.com/your-username/my-dynamics-plugin/issues"
# Set to True if this tool requires a Microsoft Graph token in addition to Dataverse
requires_graph: bool = False
def render(self, ctx: AppContext) -> None:
"""Main UI entry point called when the user selects this plugin."""
st.title(f"{self.icon} {self.name}")
st.caption(self.description)
st.write(f"Connected Environment: `{ctx.org_url}`")
4 Step 2: Authentication & Dataverse Web API Access
When render(self, ctx) is called, the AppContext object is passed with all active session details:
ctx.org_url: The URL of the connected Dataverse environment (e.g.,https://myorg.crm4.dynamics.com).ctx.user_email: The logged-in user's Entra ID principal name.ctx.auth.get_token(resource): Returns a valid Bearer token for the requested resource string.
import requests
import streamlit as st
def get_dataverse_tables(ctx: AppContext) -> list[dict]:
# 1. Acquire token from context
token = ctx.auth.get_token(ctx.org_url)
# 2. Build headers
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
"Prefer": "odata.include-annotations=\"*\""
}
# 3. Call Dataverse Web API endpoint
endpoint = f"{ctx.org_url}/api/data/v9.2/EntityDefinitions?$select=LogicalName,DisplayName,IsCustomEntity"
response = requests.get(endpoint, headers=headers, timeout=20)
response.raise_for_status()
return response.json().get("value", [])
@odata.nextLink attribute returned by Dataverse to paginate results efficiently, or restrict queries with $select and $filter.
5 Step 3: Designing the Streamlit UI
You have the entire Streamlit component ecosystem at your disposal. Common patterns include:
st.dataframe(): Interactive tables with sorting, searching, and column sizing.st.tabs(): Separate your tool into tabs (e.g., "Explorer", "Export", "Logs").st.download_button(): 1-click export of data to CSV, JSON, or Excel.st.status(): Real-time progress and multi-step operation feedback.
def render(self, ctx: AppContext) -> None:
if not ctx.org_url:
st.warning("⚠️ No Dataverse environment selected. Please enter an Org URL in the sidebar.")
return
st.subheader("Dataverse Table Inspector")
col1, col2 = st.columns([3, 1])
with col1:
entity_name = st.text_input("Enter Entity Logical Name", value="account")
with col2:
st.write("") # Spacer
execute_btn = st.button("Query Records", type="primary", use_container_width=True)
if execute_btn:
with st.spinner("Fetching entity data..."):
records = fetch_records(ctx, entity_name)
st.success(f"Retrieved {len(records)} records.")
st.dataframe(records, use_container_width=True)
6 Step 4: Configuration Settings (render_config)
If your plugin needs custom user configurations (such as default page sizes, export formats, or API preferences), override render_config(self):
class TableCounterPlugin(PluginBase):
# ... metadata ...
def render_config(self) -> None:
"""Rendered inside the Plugin Manager when clicking 'Config'."""
st.write("⚙️ **Table Counter Preferences**")
default_batch = st.slider("Default Batch Size", min_value=10, max_value=500, value=50)
include_audit = st.checkbox("Include Audit History by default", value=False)
if st.button("Save Settings"):
st.session_state["table_counter_batch"] = default_batch
st.success("Preferences saved for this session!")
7 Step 5: Managing Third-Party Dependencies
If your plugin requires external packages (like openpyxl, plotly, or pydantic), create a requirements.txt file in your plugin folder:
requests>=2.31.0
openpyxl>=3.1.2
plotly>=5.18.0
When a user installs or opens your plugin, DynamicsSuite's dependency resolver checks if these packages are present in the isolated virtual environment. If not, the Plugin Manager presents a 1-click "Install Dependencies" button!
8 Step 6: Testing Locally
Testing your new plugin is immediate and doesn't require any packaging step:
- Place your plugin directory into
DynamicsSuite/plugins/<your_plugin_id>. - Run the app using
start.bat(Windows) or./start.sh(macOS/Linux). - In the browser, go to 🔌 Installed Tools in the Plugin Manager.
- Your plugin will appear immediately in the list! Click ▶ Use to launch and test it.
9 Step 7: Packaging & Catalog Distribution
Once your plugin is tested and ready to share with your organization or the community:
1. Package into a Clean ZIP Archive
Compress your plugin folder. Ensure temporary Python cache files are excluded:
# Run from inside the DynamicsSuite/plugins directory
zip -r my_table_counter.zip my_table_counter/ -x "*.pyc" "*__pycache__*" "*.DS_Store"
2. Manifest Entry for plugins_catalog.json
Add an entry for your plugin into the catalog feed. This allows users to download or update your tool directly from the in-app Store:
{
"id": "my_table_counter",
"name": "Dataverse Table Counter",
"version": "1.0.0",
"author": "Jane Doe",
"released_by": "Acme Dynamics Solutions",
"release_date": "2026-09-08",
"description": "Quickly inspect and count records across selected Dataverse entities.",
"icon": "📊",
"download_url": "https://example.com/releases/my_table_counter.zip",
"tags": ["Dataverse", "Inspector", "Analytics"]
}
10 Complete Ready-to-Use Boilerplate Template
Here is a complete, copy-pasteable starter template for plugin.py that you can drop directly into plugins/starter_plugin/plugin.py:
import streamlit as st
import requests
from core.plugin_base import PluginBase
from core.context import AppContext
class StarterPlugin(PluginBase):
name: str = "Dataverse Environment Echo"
icon: str = "🚀"
description: str = "A clean starter template demonstrating Entra ID token usage and Dataverse API calls."
version: str = "1.0.0"
author: str = "Your Name or Organization"
support_email: str = "[email protected]"
requires_graph: bool = False
def render(self, ctx: AppContext) -> None:
st.title(f"{self.icon} {self.name}")
st.caption(self.description)
# 1. Validation check
if not ctx.org_url:
st.warning("⚠️ Please provide a Dataverse Environment URL in the sidebar.")
return
# 2. Display environment info
st.markdown(f"**Connected as:** `{ctx.user_email or 'Authenticated User'}`")
st.markdown(f"**Environment URL:** `{ctx.org_url}`")
# 3. Test API Call
if st.button("⚡ Test WhoAmI Endpoint", type="primary"):
with st.spinner("Connecting to Dataverse..."):
try:
token = ctx.auth.get_token(ctx.org_url)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
}
resp = requests.get(f"{ctx.org_url}/api/data/v9.2/WhoAmI()", headers=headers, timeout=15)
resp.raise_for_status()
data = resp.json()
st.success("✅ Connected successfully to Dataverse Web API!")
st.json(data)
except Exception as ex:
st.error(f"Failed to query Dataverse: {ex}")