| name | ep-05-custom-tools |
| description | Follow Episode 5 of the Launch Control series end-to-end — produce a .NET Custom API plugin, a Power Fx Function twin, a REST custom connector, a remote MCP custom connector, and a Power Automate test-harness flow, all programmatically against a Dataverse environment. Use when the user asks to "do Episode 5", "build the custom tools episode", "make the readiness Custom API and Fx twin", "register a REST/MCP custom connector programmatically", or "deploy the Ep 5 test harness flow". |
Skill: Episode 5 — Custom Tools (Prompt-driven)
This skill encodes Episode 5 of the Launch Control series as a sequence of
GitHub Copilot CLI prompts. The Python scripts and C# plugin in the repo
are the output of these prompts, not the input. Re-running the prompts
against a clean repo should regenerate equivalent artifacts.
The episode produces six artifacts that together demonstrate every way to
extend Dataverse with an agent-callable tool — across all four ways
to host custom business logic:
| # | Artifact | Hosting model |
|---|
| Part 1 | lc_CalculateLaunchReadiness Custom API + .NET sandbox plugin | Custom plugin — .NET in the sandbox |
| Part 2 | lc_CalculateLaunchReadinessFx Power Fx Function (twin contract, posts to Teams) | Custom function — low-code + first-party connectors |
| Part 3a | Launch Control — GitHub Releases REST custom connector | Custom connector — wrap any HTTPS REST endpoint |
| Part 3b | learn-mcp + github-mcp remote MCP custom connectors | Custom connector — wrap any remote MCP server |
| Part 4 | lc_DraftLaunchBriefing AI Prompt (Custom Action) | Custom AI function — non-deterministic LLM call |
| Part 5 | LC · Custom Tools Test Harness Power Automate flow | Single-screen verification (all four substrates) |
Hard rules
- Confirm the target environment first. Show the user the Dataverse URL
from
.env (DATAVERSE_URL) and ask them to confirm. Do not proceed
until they say yes. This skill makes irreversible changes (registers a
plugin assembly, creates Custom APIs, registers custom connectors,
creates a flow in the solution).
- Python only, per the
dv-overview skill. No Node, no PowerShell
beyond what dv-connect produced.
- Solution-first. Every artifact must land in the
LaunchControl
solution with publisher prefix lc. Confirm both at the start.
- One Part at a time. Run the prompt for Part N, verify, then move to
N+1. Don't batch — the agent needs to read each output before the next
prompt.
- Idempotent re-runs. Every Python script the agent produces should
check whether the artifact already exists and update in place rather
than failing.
Pre-flight (before any Part)
Run these checks once at the start of the episode. If any fails, stop and
remediate before running any Part prompt.
ls .env scripts/auth.py
pac org who
az account show
python -c "import requests, msal, azure.identity"
The target env must have:
- The
lc_launch and lc_milestone tables from Episodes 1–4 (containing at
least one launch named Q3 Widget Launch with several milestones).
- For Part 2 only: the "Power Platform Low Code Plug-ins" app installed by
a tenant admin (creates the
msdyn_lowcodeplugin table). If absent, Part
2 will gracefully fail — note it on screen and continue with Parts 3–4.
Part 1 — .NET Custom API + plugin
Goal
A Custom API named lc_CalculateLaunchReadiness backed by a sandboxed .NET
plugin that scores a launch by averaging weighted milestone statuses, with
verdict precedence (any Blocked → NO-GO).
Prompt to GitHub Copilot CLI
Create a Dataverse Custom API called lc_CalculateLaunchReadiness
that scores a launch by averaging its milestone statuses and returns
a verdict (GO / CONDITIONAL / NO-GO, with any Blocked milestone
forcing NO-GO). Build it as a .NET sandbox plugin so the per-milestone
reasoning can come back in a tracing-service narrative. Then register
everything programmatically into the LaunchControl solution and smoke
test it against Q3 Widget Launch.
Implementation notes (don't put these in the prompt — but the agent needs to honour them)
- .NET Framework 4.6.2 is the supported sandbox runtime as of May 2026
(4.8 lands Q4 2026). The csproj needs
Microsoft.NETFramework.ReferenceAssemblies.Net462 because the SDK no
longer ships the targeting pack by default.
- Strong-name the assembly before registration.
- Default rubric (confirm with user before locking in): Complete=100,
InProgress=60, AtRisk=50, NotStarted=20, Blocked=0. Verdict precedence:
any Blocked → NO-GO; score ≥ 90 AND zero AtRisk → GO; else CONDITIONAL.
- Custom API shape: input
lc_LaunchName (string); outputs
lc_ReadinessScore (decimal 0–100), lc_ReadinessSummary (multi-line
string), lc_Verdict (string).
- Registration script must be idempotent: PATCH the assembly bytes if the
row exists; one
AddSolutionComponent call with
AddRequiredComponents=true pulls the assembly, plugin type, request
parameter, and response properties into the LaunchControl solution
together. Use scripts/auth.py for tokens.
Verification
cd plugins/CalculateLaunchReadiness && dotnet build --configuration Release
python scripts/register_custom_action.py
python -c "
from auth import get_token
import requests, os, json
t = get_token()
r = requests.post(
os.environ['DATAVERSE_URL'] + '/api/data/v9.2/lc_CalculateLaunchReadiness',
headers={'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'},
json={'lc_LaunchName': 'Q3 Widget Launch'})
print(r.status_code, json.dumps(r.json(), indent=2))"
Expected: HTTP 200 with lc_ReadinessScore, lc_ReadinessSummary,
lc_Verdict populated.
Part 2 — Power Fx Function twin (posts to Teams)
Goal
A second Custom API with the same shape (lc_CalculateLaunchReadinessFx),
implemented as a Power Fx Function. It calls the Part 1 API for the
baseline, then posts an adaptive card to the launch's Teams channel via
the first-party MicrosoftTeams connector.
Prompt to GitHub Copilot CLI
Build a Power Fx twin of that Custom API — same shape, suffixed Fx —
as a Function in Dataverse. After scoring it should also post the
verdict to the launch's Teams channel and return the timestamp it
posted. Register it into the LaunchControl solution. If the low-code
plug-ins app isn't installed in this env, fail gracefully with the
remediation steps — don't error out.
Implementation notes
- Folder layout:
functions/CalculateLaunchReadinessFx/{formula.fx, function.json, README.md}. The README captures the exact Copilot for
Power Fx prompt used to write formula.fx so the next dev can
regenerate it.
- Contract mirrors Part 1 plus one extra response prop:
lc_NotifiedAt
(DateTime).
formula.fx calls Environment.lc_CalculateLaunchReadiness(...) for
the baseline, looks up lc_TeamsTeamId + lc_TeamsChannelId from the
launch row, then calls MicrosoftTeams.PostMessageToChannelV3(...).
Returns baseline + lc_NotifiedAt = If(posted, Now(), Blank()).
- Registration uses the
msdyn_lowcodeplugin table. If that table is
absent the registration script should print the install path (Power
Platform admin center → Environments → (env) → Resources → Dynamics
365 apps → "Power Platform Low Code Plug-ins") and exit 0, not
failure.
- A
shared_teams connection reference must already exist in the env.
Verification
python scripts/register_lowcode_function.py functions/CalculateLaunchReadinessFx
python -c "
from auth import get_token
import requests, os, json
t = get_token()
r = requests.post(
os.environ['DATAVERSE_URL'] + '/api/data/v9.2/lc_CalculateLaunchReadinessFx',
headers={'Authorization': f'Bearer {t}', 'Content-Type': 'application/json'},
json={'lc_LaunchName': 'Q3 Widget Launch'})
print(r.status_code, json.dumps(r.json(), indent=2))"
Expected: HTTP 200, identical readiness shape, plus lc_NotifiedAt
timestamp, plus a card in the launch's Teams channel.
Part 3 — Custom Endpoint Registration (REST + MCP, both programmatic)
Goal
Two demonstrations of the same primitive:
- 3a — Wrap the public GitHub Releases API as a REST custom connector.
- 3b — Wrap a remote MCP server (Microsoft Learn MCP) as an MCP custom
connector.
Both registered with one script, programmatically via the Power Apps
API (PAPI). No paconn login, no maker portal clicks.
Prompt — Part 3a (REST)
Wrap the public GitHub Releases API as a Power Platform custom
connector and register it into the LaunchControl solution
programmatically — no paconn login, no maker-portal clicks. I want
the registration script to be re-runnable against any swagger folder
in connectors/, since we'll do MCP servers next.
Prompt — Part 3b (MCP)
Now do the same for two remote MCP servers: Microsoft Learn MCP
(learn.microsoft.com/api/mcp, no auth) and the GitHub MCP server
(api.githubcopilot.com/mcp/, GitHub PAT). Re-use the registration
script from 3a — the only thing that should differ between REST and
MCP is the swagger.
Implementation notes (REST + MCP)
- Folder layout per connector:
connectors/<name>/{apiDefinition.swagger.json, apiProperties.json, settings.example.json}. .connector-id is
gitignored — the registration script writes the returned slug there for
re-runs.
- The registration script talks PAPI directly — no paconn dependency.
Use
AzureCliCredential against https://service.powerapps.com/.default.
- Create:
POST /providers/Microsoft.PowerApps/apis?api-version=2016-11-01&$filter=environment eq '<env-id>'
- Update:
PATCH /providers/Microsoft.PowerApps/apis/<id>?api-version=2016-11-01&$filter=environment eq '<env-id>'
- Body:
{"properties": {"openApiDefinition": <swagger>, "backendService": {"serviceUrl": "<scheme>://<host><basePath>"}, "environment": {"name": "<env-id>"}, "description": ..., "displayName": ..., "connectionParameters": {}, "capabilities": [], "policyTemplateInstances": [], "scriptOperations": []}}
- Gotcha 1: property name is
openApiDefinition, NOT swagger.
Wrong key → ApiDefinitionUrlInvalid 400.
- Gotcha 2: create is POST (not PUT). PUT → 405.
- Header
x-ms-origin: paconn-cli is what paconn sets — include it.
- After PAPI registration, add the connector to the LaunchControl
solution via
pac connector create --solution-unique-name LaunchControl
(or the connector Dataverse table directly).
- The "MCP magic": the swagger has one
POST /api/mcp path with
operationId: InvokeServer and the extension
"x-ms-agentic-protocol": "mcp-streamable-1.0". That single key flips
the connector framework from REST to MCP. Everything else is identical.
- GitHub MCP auth: declare
connectionParameters.api_key (header type,
Authorization header, prefix Bearer if you want raw PATs — the
GitHub MCP server also accepts token <PAT>).
Verification
python scripts/register_custom_connector.py connectors/github-releases-rest
python scripts/register_custom_connector.py connectors/learn-mcp
python scripts/register_custom_connector.py connectors/github-mcp
python -c "
from auth import get_token
import requests, os
t = get_token()
r = requests.get(
os.environ['DATAVERSE_URL'] + \"/api/data/v9.2/connectors?\\$select=name&\\$filter=startswith(name,'Launch Control')\",
headers={'Authorization': f'Bearer {t}'})
for c in r.json()['value']: print(c['name'])"
Expected: three rows (REST + 2x MCP).
Part 4 — Custom AI Function (AI Prompt → Custom Action)
Goal
A Dataverse AI Prompt named lc_DraftLaunchBriefing that pulls the
milestone narrative for a named launch and returns a 3-sentence
GO / HOLD / NO-GO recommendation in the sponsor's voice. Published via
AIModelPublish with Source="AIBuilder", grounded against
lc_launch.lc_name + lc_milestone.lc_description. Smoke-tested via
the maker portal Test button; invoked from the Part 5 flow via the
bound Predict action on msdyn_aimodels. Non-deterministic
substrate, same agent-callable surface.
Prompt to GitHub Copilot CLI
Create a Dataverse AI Prompt called lc_DraftLaunchBriefing that
takes a launch name, pulls the milestone narrative for that launch,
and drafts a three-sentence GO / HOLD / NO-GO recommendation in the
voice of the launch sponsor that I can paste straight into Teams.
Register it into the LaunchControl solution and smoke test it
against Q3 Widget Launch via the maker portal Test button.
Implementation notes (easy-to-miss bits)
- AI Prompts are rows in
msdyn_aimodel with a child training and run
msdyn_aiconfiguration pair. The recommended template is
GptDynamicPrompt-2 (TemplateId edfdb190-3791-45d8-9a6c-8f90a37c278a).
- Source-control the prompt definition under
prompts/DraftLaunchBriefing/:
prompt.json — the GptDynamicPrompt-2 body (prompt segments,
grounded data refs, model parameters)
README.md — iteration notes, invocation surfaces, publishing notes
scripts/register_ai_prompt.py calls one unbound action,
AIModelPublish, with Source="AIBuilder" (not "PowerPlatform" —
that returns 200 but leaves the model in Draft) and header
MSCRM.SolutionUniqueName: LaunchControl to land it in the solution.
This single call creates the aimodel + configurations, flips
statecode=1, and wires _msdyn_activerunconfigurationid_value —
all in one shot.
- Gotcha:
definitions.inputs[] is silently unsupported by
GptDynamicPrompt-2. Declaring any runtime input prevents publish
(model stays statecode=0). Launch context must come via grounded data
refs, not runtime inputs.
- Gotcha: data tokens must be attribute-qualified —
{"type":"data","id":"lc_launch.lc_name"}, NOT bare table. Bare
tables fail in the maker portal Test button with "Attribute name is
required for dataverse table prompt element". lc_milestone has
lc_description (NOT lc_narrative).
- There is no auto-registered unbound Custom Action. Earlier
versions of this skill claimed AI Prompts surface as
lc_<promptname>
unbound actions — they do not. The smoke test runs through the maker
portal Test button or the bound Predict action on msdyn_aimodels
(see Verification below). The flow in Part 5 calls Predict via the
Dataverse connector's PerformBoundAction.
- Raw HTTP
POST /msdyn_aimodels({id})/Microsoft.Dynamics.CRM.Predict
returns "Source is null" outside of first-party surfaces (Power
Automate, Copilot Studio, Power Fx Prompt()) — the capacity context
is set by those surfaces, not by request body. Don't try to smoke-test
via curl.
Verification
python scripts/register_ai_prompt.py prompts/DraftLaunchBriefing
Part 5 — Test harness flow (one flow exercises all four)
Goal
A single Power Automate flow with four OpenApiConnection actions — one
per substrate from Parts 1, 2, 3a, and 4. Manual trigger, returns a
Compose of all four responses. Lives in the LaunchControl solution.
Deployed programmatically via the Dataverse workflows table (no maker
portal).
Prompt to GitHub Copilot CLI
Build a Power Automate test-harness flow that exercises all four
tools in one run — the .NET Custom API, the Fx twin, the GitHub
Releases connector, and the AI Prompt — and returns their responses
side-by-side. Manual trigger, takes a LaunchName input, deploys
programmatically into the LaunchControl solution. Re-runnable; ping
me with the maker-portal URL when it's deployed so I can bind
connections and test.
Implementation notes (these are the easy-to-miss bits — get them right on the first try)
- Flow name
LC · Custom Tools Test Harness. Lives in the Dataverse
workflows table: category=5 (modern flow), type=1 (definition),
statecode=0 (draft).
- POST with header
MSCRM.SolutionUniqueName: LaunchControl to land it
in the solution.
- Solution-aware flows need connection references, not connection
names. Idempotently ensure two
connectionreference rows exist:
lc_dataverse_harness (connectorid = /providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps)
and lc_githubreleases_harness (pointing at the GH connector slug
from Part 3a). Look up by connectionreferencelogicalname first;
create only if missing.
clientdata.properties.connectionReferences is a map whose keys are
the connection-reference logical names. Each value:
{"connection": {"connectionReferenceLogicalName": "<name>"}, "api": {"name": "<connector slug>"}, "runtimeSource": "embedded"}.
- Each action's
host block uses connectionReferenceName: "<map key>"
— NOT connectionName.
- For unbound Custom API calls (
PerformUnboundAction), input params
go at the TOP LEVEL of parameters — NOT under item/. Using
item/lc_LaunchName is the bound-action shape; the unbound action
rejects it as "no longer present in the operation schema".
- For the AI Prompt (Part 4), the connector action is bound, not
unbound. Use
PerformBoundAction against msdyn_aimodels with
actionName: "Predict", recordId: <aimodel-guid>, and version: "2.0". Resolve the aimodel-guid at deploy time by querying
msdyn_aimodels?$filter=msdyn_name eq 'lc_DraftLaunchBriefing'. The
Power Automate connector handles AI Builder capacity context — raw
HTTP Predict does not.
- Do NOT include
"authentication": "@parameters('$authentication')"
on OpenApiConnection actions. Power Automate auto-injects this and
rejects the flow if present (should not have the property 'authentication').
- PATCH can't change
clientdata's connection-reference schema in
place. If a flow with the same name exists, DELETE and recreate.
- Discover the GH connector slug at runtime by querying PAPI by
displayName (
/providers/Microsoft.PowerApps/apis?$filter=environment eq '<env>', filter client-side for displayName == "Launch Control — GitHub Releases"). If missing, deploy a partial 2-action flow and
warn rather than failing.
- Print the maker-portal URL on success:
https://make.powerautomate.com/environments/<env>/flows/<workflow-id>.
Verification
python scripts/create_test_harness_flow.py
Expected: the run history shows four green actions, and the Compose
output contains a verdict from the .NET API, a verdict + lc_NotifiedAt
from the Fx twin, a tag_name from GitHub Releases, and three sentences
of exec briefing from the AI prompt's Predict response.
Order of operations summary
[pre-flight checks]
│
▼
[Part 1] dotnet build → register_custom_action.py → smoke-test the Custom API
│
▼
[Part 2] register Fx Function → invoke → confirm Teams card posted
│
▼
[Part 3a] register_custom_connector.py connectors/github-releases-rest
│
▼
[Part 3b] register_custom_connector.py connectors/learn-mcp
register_custom_connector.py connectors/github-mcp
│
▼
[Part 4] register_ai_prompt.py prompts/DraftLaunchBriefing → smoke-test twice
│
▼
[Part 5] create_test_harness_flow.py → bind connections in portal → Test → Run
If a Part fails, stop and remediate before moving on. Each Part's output
is an input to the next (Part 5 needs the GH connector slug from Part 3a,
the .NET API from Part 1, the Fx twin from Part 2, and the AI prompt
action name from Part 4).
Cleanup (between recording takes)
To wipe all Ep 5 artifacts and re-record from a clean state, run the
teardown script. It removes the plugin assembly + CustomAPIs, custom
connectors (REST + MCP), AI Prompt + configs, the test harness flow,
and any sample AI Prompt rows — while leaving the LaunchControl
solution shell and the lc_launch / lc_milestone data from
Episodes 1–4 intact.
python scripts/teardown_ep05.py
python scripts/teardown_ep05.py --confirm
Re-run from Part 1 after teardown.
Related skills
dv-overview — workspace + auth setup; multi-environment rules.
dv-data — used by the Part 1 verification (record lookup via SDK).
dv-metadata — Custom API definition is metadata; this skill creates it
via raw Web API because it's a single registration call, but
dv-metadata covers the general pattern.
dv-solution — the LaunchControl solution this episode targets; if it
doesn't exist, create it via that skill first.