⚡ Developer Guide & Reference

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.
Key Rule: Plugin authors must never manage their own OAuth flows or save credentials to disk. Always obtain tokens using 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).

Directory Structure TEXT
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:

plugins/my_table_counter/plugin.py
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.
Querying Dataverse Web API
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", [])
Performance Tip: For large record sets, always respect the @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.
Example Streamlit UI Structure
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):

plugins/my_table_counter/plugin.py (Optional Config)
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:

plugins/my_table_counter/requirements.txt
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:

  1. Place your plugin directory into DynamicsSuite/plugins/<your_plugin_id>.
  2. Run the app using start.bat (Windows) or ./start.sh (macOS/Linux).
  3. In the browser, go to 🔌 Installed Tools in the Plugin Manager.
  4. 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:

Terminal Command
# 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:

Catalog JSON Entry
{
  "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"]
}
Submitting to Official Feed: To have your plugin listed in the official Bigscene catalog (DynamicsSuite.bigscene.uk), submit a pull request on the repository or contact the Bigscene engineering team with your package URL!

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:

Complete Boilerplate: 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}")