# Ensure Pipelines Host

> Ensures the tenant has a usable Power Platform Pipelines host environment before any pipeline operation runs. Detects host state via the same resolution order as the Power Apps UI (org-db setting → BAP env metadata → default-custom-host setting); if any existing host (Platform or Custom) is found, uses it. If no host is bound to the source env, provisions a new **Platform Host** (recommended, idempotent) or a **Custom Host** via the BAP env-create API with the `D365_ProjectHost` template, or guides the user through PPAC install / `New custom host` (manual fallbacks). Polls lifecycle operations, verifies the host responds to Pipelines API calls, writes a host-check artifact other ALM skills consume. Use when asked to: "set up pipelines host", "ensure pipelines host", "no pipelines host", "install pipelines", "create pipelines host", "provision platform host", "provision custom host". Also invoked transparently by /power-pages:setup-pipeline when its host discovery step finds nothing.

- Skill: `microsoft/ensure-pipelines-host` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add microsoft/ensure-pipelines-host`
- Raw SKILL.md: https://api.skillmd.com/api/skills/microsoft/ensure-pipelines-host/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Product & Planning
- Author: Microsoft (https://skillmd.com/u/microsoft)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/microsoft/ensure-pipelines-host

---


> **Plugin check**: Run `node "${PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding.

<!-- alm-lint-ignore: SKILL-must-read-manifest — this skill manages the Pipelines host environment (deploymentenvironments / deploymentpipelines tables on the host), not the source-env solution. The site's .solution-manifest.json is irrelevant to host lifecycle: a host can be provisioned before any solution exists, and a single host is shared across many solutions. ALM-aware-by-default does not apply. -->

# ensure-pipelines-host

> **Scope:** When no host is bound to the source env, this skill detects any existing host (Custom or PE) for reuse, or — in `NoHost` state — offers three provisioning paths: a new **Platform Host** (recommended; idempotent, ~3–5 min); a new **Custom Host** (admin-only, ~5–10 min); or PPAC manual provisioning (fallback). Implementation details — endpoint names, template names, BAP audience — live in Phase 4.0 / 4.A / 4.C below; user-facing prose stays focused on outcomes.

Power Platform Pipelines need a **host environment** — a Dataverse environment with the *Power Platform Pipelines* managed solution installed, where pipelines, stages, run history, and artifacts live. The existing `setup-pipeline` and `deploy-pipeline` skills assume a host is already configured. This skill closes that gap.

## What we know (sources of truth)

This plan is grounded in three primary sources, in priority order:

1. **`useGetOrCreatePlatformEnvironment.v4.ts`** (Microsoft-internal client source — `power-platform-ux/packages/powerapps-appdeployment-ux/src/hooks/v4/`). Defines the exact HTTP contract for Platform Environment provisioning: endpoint, body, headers, polling.
2. **`ProjectHostProvider.tsx`** (same repo, `src/components/ProjectHostProvider/`). Defines the exact resolution order the Power Apps UI uses to determine which environment is the project host for a source environment. We mirror that order so this skill agrees with the UI.
3. **eng.ms `createcustompipelineshost`** (Microsoft-internal). Documents the Custom Host fast-path: a `D365_ProjectHost` org template that ships the Pipelines app pre-installed, callable through the standard environment-creation API.

Public Microsoft Learn (`learn.microsoft.com/power-platform/alm/{platform-host-pipelines, custom-host-pipelines, set-a-default-pipelines-host}`) is the user-facing description of the same flows; we cite it for behaviors users will recognize. HARs in `PipelinesDeployScenario.har` and `Pipelines.har` confirm the read-side calls.

## Three host shapes the tenant can be in

| Shape | How it got there | Where it lives | Org template |
|---|---|---|---|
| **Platform Host (PE)** | Auto-provisioned by `getOrCreate` BAP call (or as a side-effect of first navigation to the Pipelines page in `make.powerapps.com`). Hidden from the env picker. One per tenant. | Microsoft-managed Dataverse env in tenant's home geo | `D365_1stPartyAdminApps` |
| **Custom Host** | Created by an admin via PPAC `Deployments → New custom host`, or via the standard env-create API with the `D365_ProjectHost` template, or by installing the Power Platform Pipelines app on an existing Dataverse env. | A regular Dataverse env in the tenant | `D365_ProjectHost` (or app-installed-onto-existing-env) |
| **No host bound to source env** | Tenant has not used Pipelines from this env. | — | — |

The current `discover-pipelines-host.js` only checks the tenant-level `DefaultCustomPipelinesHostEnvForTenant` setting. That's one signal of many. This skill implements the full resolution order.

## Resolution order (mirrors `ProjectHostProvider.tsx`)

This is the load-bearing decision tree. It is what the Power Apps UI does. We replicate it so the skill agrees with the UI.

```
┌─────────────────────────────────────────────────────────────────────┐
│ 1. GetOrgDbOrgSetting('ProjectHostEnvironmentId') on source env     │
└──────────────────────────┬──────────────────────────────────────────┘
                           │
            ┌──────────────┴───────────────┐
            │ value present                │ value empty
            ▼                              ▼
┌───────────────────────┐         ┌────────────────────────────┐
│ 2. Resolve env via    │         │ 5a. Tenant-wide search:    │
│    BAP GET            │         │   list envs + per-env      │
│    /environments/{id} │         │   /deploymentpipelines     │
└───────┬───────────────┘         │   probe.                   │
        │                         │                            │
   environmentSku?                │   - 1 Custom Host found →  │
        │                         │     AvailableUnboundCustom │
   ┌────┴────────────┐            │     (3.C-pre)              │
   │ Platform        │            │   - >1 Custom Hosts →      │
   │                 │            │     MultipleUnboundCustom  │
   │                 │            │     (3.C-pre')             │
   │                 │            │   - PE only →              │
   │                 │            │     PlatformHostExists-    │
   │                 │            │     Unbound (3.C-pre'')    │
   │                 │            │   - none → NoHost (3.C)    │
   │                 │            │                            │
   │                 │            │ 5b. Decision tree paths    │
   │                 │            │   for create-new (3.C):    │
   │                 │            │   - Platform getOrCreate   │
   │                 │            │     (fast-path, no admin)  │
   │                 │            │   - Custom D365_ProjectHost│
   │                 │            │     (fast-path, admin)     │
   │                 │            │   - Manual app install     │
   │                 │            │   - Manual PPAC create     │
   │                 │            └────────────────────────────┘
   ▼                 │
┌──────────────┐     │
│ 3. Check     │     │ environmentSku ≠ Platform (Custom Host)
│  Default-    │     ▼
│  Custom-     │   ┌──────────────────────────────┐
│  Pipelines-  │   │ 4. Use the Custom Host       │
│  HostEnv-    │   │    directly. Skip default-   │
│  ForTenant   │   │    custom check.             │
└──────┬───────┘   └──────────────────────────────┘
       │
   ┌───┴────────────────────────┐
   │ admin set a custom default │
   │                            │
   ▼                            ▼
┌─────────────────┐  ┌─────────────────────────┐
│ default ==      │  │ default !=              │
│ org setting?    │  │ org setting             │
│                 │  │                         │
│ → use default   │  │ → CannotRedirect ERROR  │
│   custom        │  │   (user locked to PE    │
└─────────────────┘  │   but admin overrode    │
                     │   at tenant scope)      │
                     └─────────────────────────┘

   if no admin default → use PE
```

Source: `ProjectHostProvider.tsx` lines 100–213 (orgSetting fetch → defaultCustomPipelinesHost fetch → finalProjectHostEnvironmentId resolution).

## What this skill does NOT do

These are deliberate non-goals (each based on a hard constraint or a destructive blast-radius — see *Design Constraints* below):

- **Does not silently provision anything.** Any action that creates an env or binds the source env to a host requires explicit user confirmation, with the tenant name + tenant ID echoed back. PE is tenant-singleton and admin-non-deletable, so the Phase 4.0 pre-call confirmation gate is the principal mitigation against wrong-tenant provisioning. The `getOrCreate` endpoint is idempotent — calling it on a tenant that already has a PE returns the existing one rather than creating a duplicate.
- **Does not call `Force Link`** to rebind an environment to a different host. Force Link is destructive (makers lose access to existing pipelines in the previous host) and is hidden behind a separate confirmation gate, only reachable when the user explicitly says "rebind".
- **Does not change the tenant-level `DefaultCustomPipelinesHostEnvForTenant` setting.** That setting is irreversible-adjacent (existing pipelines in the previous default become inaccessible — see `learn.microsoft.com/power-platform/alm/set-a-default-pipelines-host`). Out of scope.
- **Does not delete environments.**
- **Does not write `ProjectHostEnvironmentId` directly.** Binding is established through the documented Pipelines flow (creating a `deploymentenvironment` record in the host); writing the org setting directly bypasses validation.

## Auth strategy: PAC-first with BAP fallback (`--source auto`)

Read-side detection (Phase 2 resolution order, env list, env-by-id) defaults to `--source auto`:
1. If a BAP token is provided, **try BAP env-list / env-GET first** (richer data including `lastModifiedTime`, `permissions`, `tenantId`).
2. **On HTTP 401 or 403, fall back to `pac admin list --json`** via `pac-bap-shim.js`. PAC has its own first-party client-ID grants on BAP that Az CLI doesn't always inherit (verified 2026-04-28: `D365DemoTSCE53051106` demo tenant rejects Az tokens for BAP even with correct audience claims).
3. If no BAP token is provided at all, go straight to PAC.

The PAC shim returns BAP-shaped data; downstream code (sku filter, ranking, classification) is unchanged. Fields not provided by PAC (`tenantId`, `lastModifiedTime`, `permissions`, `isManaged`) come back as `null` — none are critical for host detection. PAC also doesn't surface Platform Hosts (PE) since `pac admin list` doesn't include Platform-sku envs; PE detection requires `--source bap` with a working BAP token.

**Write-side actions** (env-create POST in `provision-custom-host.js`, lifecycle op polling) still require BAP. Az CLI tokens with the right audience usually work for these even when env-list calls fail, because the BAP RP enforces different policy on actions than reads. If `provision-custom-host.js` returns 401, the user must register a service principal in the target tenant (or use the PPAC UI fallback path 4.C).

## Design Constraints

1. **JIT provisioning is required when a PE is selected — existing or freshly provisioned.** From `ProjectHostProvider.tsx` (line 232–240 comment): *"In the Platform Environment case, the user may not already be provisioned there, so BAP cannot discover it. So we'll use the org URL we retrieve from the getOrCreate call to make this first request so that user JIT can be triggered."* When Phase 2 detects an existing PE and the user accepts it (Phase 3.A) — or when Phase 4.0 provisions a new PE via `getOrCreate` — Phase 5's `WhoAmI` call against `instanceApiUrl` triggers JIT before any subsequent host op. (For Custom Host paths the caller has access by construction.)
2. **`CannotRedirect` is a real terminal state**, not a theoretical edge case. It happens when `ProjectHostEnvironmentId` (org setting on source env) points at PE but `DefaultCustomPipelinesHostEnvForTenant` (admin tenant setting) points elsewhere. The skill must detect this and surface it as a specific error — falling through silently would route pipeline ops at the wrong host.
3. **Admin-only Custom Host fast-path.** PPAC's `New custom host` flow is gated by `DeploymentHubCreatePipelinesHostForAdminsOnly` and shows only for Global / Power Platform / Dynamics admins (eng.ms doc). The BAP env-create API also needs the equivalent privilege. Non-admins get 403; the skill preflight-attestation-prompts and gracefully falls back to manual paths.
4. **404 from BAP env GET is ambiguous.** Returns 404 for *deleted*, *disabled*, *no-PE*, and *no-access* without distinguishing (`PowerPipelines_PE_Knowledge.md` §6.A). We never treat a single 404 as "no host exists" — we corroborate via list-environments and the org setting before acting.
5. **Each environment is bound to only one host at a time.** Rebinding requires Force Link, which is destructive in the previous host. Out of scope (see non-goals).
6. **The skill runs in user OAuth context** — same scope and audience the Power Apps UI uses. BAP calls use `https://service.powerapps.com/` audience.

> **Idempotency of `getOrCreate`** — the BAP `getOrCreate` endpoint is idempotent (existing PE returns 200 + `provisioningState === 'Succeeded'`; new PE returns 202 + lifecycle op). Phase 4.0 leverages this — calling getOrCreate on a tenant that already has a PE is safe and just returns the existing one. The `provision-platform-host.js` helper surfaces the distinction via an `alreadyExisted: true | false` flag in its return value (recorded in the `docs/alm/last-host-check.json` telemetry block as `platformHostAlreadyExisted`).

## Prerequisites

- PAC CLI logged in (`pac env who` succeeds)
- Azure CLI logged in (`az account show` succeeds)
- A source Dataverse environment URL (read from `powerpages.config.json` if invoked from a Power Pages project; passed as arg otherwise)
- For Phase 4 admin-only paths: caller has Global / Power Platform / Dynamics admin (skill detects and surfaces 403 cleanly if missing)

## Phases

### Phase 1 — Detect prerequisites and gather tenant context

**Create all tasks upfront at the start of this phase.**

Tasks to create:

1. "Check local cache and detect prerequisites"
2. "Run resolution order to find host"
3. "Confirm action with user"
4. "Execute chosen path"
5. "JIT-provision and verify host"
6. "Write host-check artifact"

Steps:

0. **Local cache fast-path.** If `docs/alm/last-host-check.json` exists AND `Date.now() - Date.parse(checkedAt) < cacheMaxAgeMs` (default 24h; configurable via `--cacheMaxAgeHours`):
   - Acquire `HOST_TOKEN` for the cached `finalHostEnvUrl` origin.
   - One cheap probe: `GET {finalHostEnvUrl}/api/data/v9.0/solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'&$select=version&$top=1` (proves Pipelines is installed AND captures version in one round-trip)
     - 200 → cache is valid. Set `RESOLUTION` from the cached file. Set `ACTION_TAKEN = "none"`. Skip Phases 2–5; jump to Phase 6 with a "reused cached host" summary.
     - 404 / 403 / timeout / network → cache is stale or no longer accessible. Continue to Step 1 (full resolution). Do NOT fail — stale cache is expected after env deletion or permission changes.
   - If the file is missing, malformed, older than `cacheMaxAgeMs`, or contains `ready: false` → continue to Step 1.
   - **Skip this step entirely** if `--no-cache` is passed (used in CI / smoke tests).

1. Run `verify-alm-prerequisites.js`:
   ```bash
   node "${PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js"
   ```
   Capture `.envUrl` (`devEnvUrl`), `.token` (`DEV_TOKEN`), `.userId`, `.tenantId`, `.organizationId`. Stop on auth failure with the script's remediation message.

2. Run `detect-project-context.js` (non-fatal — skill is also valid outside a Power Pages project):
   ```bash
   node "${PLUGIN_ROOT}/scripts/lib/detect-project-context.js"
   ```
   Capture `.siteName` and `.solutionManifest` for messaging.

3. Acquire BAP token (different audience than Dataverse):
   ```bash
   az account get-access-token --resource "https://service.powerapps.com/" --query accessToken -o tsv
   ```
   Store as `BAP_TOKEN`. This is used by all BAP `/providers/Microsoft.BusinessAppPlatform/...` calls in Phases 2 and 4.

3a. **Resolve tenant display name** (one-shot, best-effort). Phase 1.4 and Phase 4.0 echo a human-readable tenant name alongside the tenant GUID so the user can verify the target tenant. Acquire it from the Microsoft Graph organization endpoint:

   ```bash
   az rest --method GET --url "https://graph.microsoft.com/v1.0/organization?$select=id,displayName" --resource "https://graph.microsoft.com/" --query "value[0].displayName" -o tsv
   ```

   Store as `TENANT_DISPLAY_NAME`. On any failure (no Graph permission, network error, multi-tenant ambiguity), fall back to `TENANT_DISPLAY_NAME = null` and continue — Phase 1.4 / 4.0 prompts handle a null display name by showing the tenant GUID alone.

<!-- gate: ensure-pipelines-host:1.4.tenant-identity | category=consent | cancel-leaves=nothing -->
> 🚦 **Gate (consent · ensure-pipelines-host:1.4.tenant-identity):** Echo tenant display name + tenant GUID + dev env URL before any host detection. First of the wrong-tenant guards. Cancel exits cleanly before any BAP/Dataverse call.

4. **Tenant identity confirmation gate.** Echo back via `AskUserQuestion`:

   > "About to inspect Pipelines host configuration for tenant **{TENANT_DISPLAY_NAME}** (`{tenantId}`), org `{organizationId}`, dev env `{devEnvUrl}`. Continue? 1. Yes / 2. Cancel"

   (When `TENANT_DISPLAY_NAME` is null, drop the bold tenant-name segment and lead with the tenant GUID.)

   First of the consent gates that guard against wrong-tenant operations.

### Phase 1.5 — Ground in current Pipelines host documentation

> Reference: `${PLUGIN_ROOT}/references/alm-docs-grounding.md`

Cap this step at ~30 seconds. If MCP search / fetch errors out, log a one-line note and continue — this skill must remain runnable offline.

1. Run `microsoft_docs_search` with the query: `Power Platform Pipelines host environment Platform Host Custom Host`.
2. Fetch `https://learn.microsoft.com/en-us/power-platform/alm/pipelines` (and at most one sister page on host setup, default-custom-host configuration, or admin role requirements) in parallel via `microsoft_docs_fetch`.
3. Extract a one-paragraph summary of what Microsoft Learn currently says about Platform vs Custom Host trade-offs, the resolution order (org-db setting → BAP env metadata → tenant default), and admin role requirements. Compare against this skill's own *Resolution order* section and `${PLUGIN_ROOT}/references/cicd-pipeline-patterns.md`; flag any divergence (e.g. new Platform-Host SKU, changed default-custom-host setting name, new tenant policy controls).
4. Use the summary to inform Phase 2+ decisions. Do not silently change skill behavior — surface any divergence to the user as a soft warning before Phase 3 (Confirm action with user).

### Phase 2 — Run resolution order to find host

This phase is read-only. It produces a `RESOLUTION` object the user-confirm phase branches on.

The phase mirrors `ProjectHostProvider.tsx` exactly. The `useState` variables in that hook map to fields in our `RESOLUTION`:

| TS variable | Our field |
|---|---|
| `orgSetting.orgDbOrgSettingValue` | `orgSettingHostEnvId` |
| `initialProjectHostEnvironmentId` | (same) |
| `isInitialHostPlatformEnvironment` | `isPlatform` |
| `defaultCustomPipelinesHost` | `tenantDefaultCustomHostEnvId` |
| `finalProjectHostEnvironmentId` | `finalHostEnvId` |
| `projectHostStatus` | `status` |

Steps:

1. **Org-setting probe** (mirrors `useGetOrgDbOrgSetting('ProjectHostEnvironmentId')` line 103 in tsx). New helper `check-env-host-binding.js`:

   ```
   POST {devEnvUrl}/api/data/v9.0/GetOrgDbOrgSetting
   Authorization: Bearer {DEV_TOKEN}
   Body: { "SettingName": "ProjectHostEnvironmentId" }
   ```
   - Empty `SettingValue` → no current binding. Skip to Step 4.
   - Non-empty → store as `orgSettingHostEnvId`. Continue to Step 2.

2. **Resolve env via BAP** (mirrors `useGetEnvironmentByName(initialProjectHostEnvironmentId)` line 483 in tsx). New helper `resolve-env-by-id.js`:

   ```
   GET https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/{envId}?api-version=2020-06-01&$expand=properties.linkedEnvironmentMetadata,properties.permissions
   Authorization: Bearer {BAP_TOKEN}
   ```
   - 200 → capture `environmentSku`, `displayName`, `linkedEnvironmentMetadata.instanceApiUrl`, `linkedEnvironmentMetadata.instanceUrl`. Set `RESOLUTION.isPlatform = (environmentSku === 'Platform')`.
   - 404 → **disambiguate before acting** (Constraint 5). Run `list-tenant-envs.js` (Step 5) and check whether the env is in the list:
     - If listed → user lacks access → set `RESOLUTION.status = "PermissionDenied"`, surface to user, stop.
     - If not listed → env is genuinely deleted/disabled → set `RESOLUTION.status = "OrgSettingStale"`, recommend the user clear `ProjectHostEnvironmentId` and re-run, stop.
   - 403 → set `RESOLUTION.status = "PermissionDenied"`, stop.

3. **If `isPlatform === true`**, mirror the default-custom-tenant-setting check (lines 148–213 in tsx). Reuse the existing `discover-pipelines-host.js`:

   ```bash
   node "${PLUGIN_ROOT}/scripts/lib/discover-pipelines-host.js" \
     --envUrl "{devEnvUrl}" --token "{DEV_TOKEN}" --userId "{userId}"
   ```
   - `found: false` → tenant has no admin default custom host. `finalHostEnvId = orgSettingHostEnvId` (the PE). Set `RESOLUTION.status = "AvailableUsingPlatformHost"`.
   - `found: true` AND `hostEnvUrl` matches `orgSettingHostEnvId` → admin-default agrees with org setting. `finalHostEnvId = orgSettingHostEnvId`. Set `RESOLUTION.status = "AvailableUsingCustomHostByAdminDefault"`.
   - `found: true` AND `hostEnvUrl` does NOT match `orgSettingHostEnvId` → **`CannotRedirect`** (Constraint 3). Set `RESOLUTION.status = "CannotRedirect"`, capture both URLs. Stop with the specific error message — only an admin can resolve this.

   **If `isPlatform === false`** (Custom Host): use directly. `finalHostEnvId = orgSettingHostEnvId`. Set `RESOLUTION.status = "AvailableUsingCustomHost"`. Skip Step 4–5; jump to Step 6.

4. **No org setting → tenant-wide search before declaring NoHost.** Source env isn't bound, but a usable Custom Host may already exist in the tenant (admin-created, or created by a prior run of this skill in another project). Always inventory before offering to create.

5. **Tenant env inventory + Pipelines-presence probe** (decisional — feeds `RESOLUTION.status`). New helper `list-tenant-envs.js`:

   **Step 5a — list envs:**
   ```
   GET https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments?api-version=2020-06-01&$expand=properties.linkedEnvironmentMetadata
   Authorization: Bearer {BAP_TOKEN}
   ```
   For each env capture `{ envId, displayName, environmentSku, instanceApiUrl, isManaged, hasDataverse: !!instanceApiUrl }`.

   **Step 5b — Pipelines-presence probe per env** (parallel, max 10 concurrent; bounded by sku filter + maxEnvsToProbe cap):

   **Pre-filter** (avoid probing every env in large tenants — recon found tenants with 1000+ envs):
   - Skip envs without Dataverse (`linkedEnvironmentMetadata.instanceApiUrl == null`).
   - Skip envs not in `--skus` (default: `Production,Sandbox` — both are valid hosts for the Pipelines app via Phase 4.B install-on-existing). PE always reports `environmentSku === 'Platform'` and is included regardless. Pass `--skus Production,Sandbox,Trial` to include Trial envs (eligible for app-install via 4.B but **not** for env-create via 4.A — Trial-license tenants get `NotEnoughCapacity_HasTrialLicense` from env-create).
   - Sort remaining by `lastModifiedTime` desc.
   - Cap at `--maxEnvsToProbe` (default 50; covers the typical-tenant 80% case in <5s with 10-concurrent).
   - If cap is reached and no host found, surface a warning: `"Scanned N of M envs (filter: Production+Sandbox, sorted by lastModifiedTime). Pass --maxEnvsToProbe N+ or --skus Production,Sandbox,Trial to widen."`

   **Probe query** (single query covers presence-check AND version-capture):
   ```
   GET {instanceApiUrl}/api/data/v9.0/solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'&$select=uniquename,version&$top=1
   Authorization: Bearer {HOST_TOKEN-per-env}
   OData-Version: 4.0
   OData-MaxVersion: 4.0
   ```
   - 200 with `value.length === 1` → Pipelines installed. Capture `value[0].version` as `pipelinesSolutionVersion`. **Mark as Custom Host candidate** (or PE if `environmentSku === 'Platform'`).
   - 200 with `value.length === 0` → no Pipelines. If Dataverse + caller has access, mark `eligible-for-app-install`.
   - 404 → entity exists but Dataverse unreachable / wrong URL; treat as not-a-candidate.
   - 403 → caller cannot access; do NOT count as a host candidate. Add to `inaccessibleEnvs[]` for warnings only.
   - timeout / 5xx → log to warnings, treat as not-a-candidate; do not retry.

   > **Why not `deploymentpipelines?$top=0`?** Dataverse rejects `$top=0` on `deploymentpipelines` with HTTP 400 "Invalid value for $top query option" even on a properly installed host (verified against `pascalepipelineshost.crm.dynamics.com` 2026-04-28). The `solutions?$filter=uniquename eq 'msdyn_AppDeploymentAnchor'` query is the correct cheap probe — single round-trip, no rate-limit concerns at $top=1, and it returns the version we need anyway.

   **Token strategy for 5b**: acquire one HOST_TOKEN per distinct env origin via `az account get-access-token --resource "{origin}"`, cached in-memory for the run. Token acquisition itself shouldn't fail unless the resource doesn't exist (deleted env), in which case skip.

   **Output of step 5** (`RESOLUTION.candidates`):
   ```js
   {
     existingCustomHosts: [{ envId, instanceApiUrl, displayName, environmentSku, pipelinesSolutionVersion }, ...],
     existingPlatformHost: { envId, instanceApiUrl, displayName, ... } | null,  // at most one
     eligibleForAppInstall: [{ envId, instanceApiUrl, displayName }, ...],
     inaccessibleEnvs: [{ envId, displayName, reason: "403" | "timeout" }, ...]
   }
   ```

   **Decision logic** (sets `RESOLUTION.status`):
   - `existingCustomHosts.length === 1` → `RESOLUTION.status = "AvailableUnboundCustomHost"`. Set `finalHostEnvId / finalHostEnvUrl` provisionally to that host (Phase 3.C-pre will confirm).
   - `existingCustomHosts.length > 1` → `RESOLUTION.status = "MultipleUnboundCustomHosts"`. Phase 3.C-pre' will ask user to pick.
   - `existingCustomHosts.length === 0` AND `existingPlatformHost !== null` → `RESOLUTION.status = "PlatformHostExistsUnbound"`. Phase 3.C-pre'' offers PE-use (no creation needed; a PE already lives in the tenant). Note: actual binding of source env to PE happens through the documented Pipelines flow when `setup-pipeline` registers source env in `deploymentenvironments`, same as Custom Host.
   - All zero → `RESOLUTION.status = "NoHost"`. Phase 3.C decision tree (create-new).

   > **Self-detection note:** Custom Hosts created by previous runs of this skill (Phase 4.A `D365_ProjectHost` template) install the Pipelines solution as part of the template. They surface in `existingCustomHosts` on the *exact same signal* as admin-created hosts. We do not need a marker on hosts we created ourselves — the Pipelines-solution-installed signal is sufficient.

6. **Pipelines solution version probe** (only when `finalHostEnvId` is known and not already populated by step 5b). On the resolved host instanceUrl:

   ```
   GET {hostEnvUrl}/api/data/v9.0/solutions?$filter=uniquename eq '{PIPELINES_SOLUTION_UNIQUE_NAME}'&$select=version,installedon
   Authorization: Bearer {HOST_TOKEN}
   ```

   Where `HOST_TOKEN` is acquired against `{hostEnvUrl origin}` via `az account get-access-token --resource`.

   The solution's exact unique name is an open item — see *Open Items*. Working hypothesis: `msdyn_AppDeploymentAnchor`. Capture `PIPELINES_SOLUTION_VERSION`. If query returns empty (solution missing on a non-PE host) → `RESOLUTION.status = "HostWithoutPipelines"` — Phase 3.D path.

Report findings to user:

> "Tenant `{tenantId}` host status: **{RESOLUTION.status}**. {Status-specific summary line.}"

### Phase 3 — Confirm action with user

Branches by `RESOLUTION.status`. Each branch ends with either *"proceed to Phase 5"* (host already usable) or *"Phase 4 with chosen path"*.

#### 3.A — Status `AvailableUsingCustomHost` / `AvailableUsingCustomHostByAdminDefault` / `AvailableUsingPlatformHost`

Host is established. Confirm and skip ahead.

> "Found existing host: `{finalHostEnvUrl}` (`{RESOLUTION.status}`, Pipelines solution v`{PIPELINES_SOLUTION_VERSION}`). Use this host?
> 1. Yes — proceed to verification
> 2. Cancel"

- Yes → set `ACTION_TAKEN = "none"`, jump to Phase 5.
- Cancel → exit.

#### 3.B — Status `CannotRedirect`

Locked state. Cannot proceed.

> "Cannot proceed: `ProjectHostEnvironmentId` on `{devEnvUrl}` points at the Platform Host (`{orgSettingHostEnvId}`), but the tenant admin set `DefaultCustomPipelinesHostEnvForTenant` to a different env (`{tenantDefaultCustomHostEnvId}`). The Pipelines UI cannot redirect this env to the admin's choice. Resolution requires a Power Platform admin to either (a) clear the tenant default, or (b) update the org setting on this env. Exiting."

Stop.

#### 3.C-pre — Status `AvailableUnboundCustomHost` (single existing Custom Host found)

Tenant already has exactly one Custom Host with Pipelines installed. Source env isn't bound to it yet, but binding happens automatically when `setup-pipeline` registers the source env in the host's `deploymentenvironments` table. **Reusing avoids creating duplicate hosts.**

> "Found an existing Custom Host in tenant `{tenantId}`:
> - **Display name:** `{displayName}`
> - **URL:** `{instanceApiUrl}`
> - **Pipelines solution:** v`{pipelinesSolutionVersion}`
>
> Source env `{devEnvUrl}` is not yet bound to it — that will happen automatically the first time `setup-pipeline` runs against this host. Use this host?
> 1. Yes — use existing host (recommended; avoids duplicates)
> 2. No — show me the create-new decision tree (Phase 3.C)
> 3. Cancel"

- Yes → set `finalHostEnvUrl/Id`, `ACTION_TAKEN = "reuse-existing-custom"`, jump to Phase 5.
- No → fall through to Phase 3.C `NoHost` decision tree (still allows creating another).
- Cancel → exit.

#### 3.C-pre' — Status `MultipleUnboundCustomHosts` (multiple existing Custom Hosts found)

> "Found {N} existing Custom Hosts in tenant `{tenantId}` with Pipelines installed. Pick one to use, or create new:
>
> 1. `{host[0].displayName}` (`{host[0].instanceApiUrl}`, Pipelines v`{host[0].pipelinesSolutionVersion}`)
> 2. `{host[1].displayName}` (...)
> ...
> N. ...
> N+1. **Create new Custom Host instead** — go to Phase 3.C decision tree
> N+2. Cancel"

- Selection 1..N → set `finalHostEnvUrl/Id` from picked host, `ACTION_TAKEN = "reuse-existing-custom"`, jump to Phase 5.
- N+1 → fall through to Phase 3.C `NoHost` decision tree.
- N+2 → exit.

#### 3.C-pre'' — Status `PlatformHostExistsUnbound` (PE already exists, no Custom Host)

A PE already exists in the tenant (one is provisioned automatically the first time anyone navigated to the Pipelines page). Per scope decision, this iteration does NOT auto-provision a PE, but if one already exists, we offer to use it.

> "Tenant `{tenantId}` already has a Platform Host (`{instanceApiUrl}`, Pipelines v`{pipelinesSolutionVersion}`). Source env is not yet bound to it. Use this host?
> 1. Yes — use existing Platform Host (idempotent — already provisioned in this tenant)
> 2. No — create a Custom Host instead (Phase 3.C decision tree)
> 3. Cancel"

- Yes → set `finalHostEnvUrl/Id` from PE, `ACTION_TAKEN = "reuse-existing-pe"`, jump to Phase 5. (Phase 5's WhoAmI call triggers JIT — Constraint 1.)
- No → fall through to Phase 3.C `NoHost` decision tree (admin-created Custom Host preferred for governance).
- Cancel → exit.

#### 3.C — Status `NoHost` (host-type decision tree)

<!-- gate: ensure-pipelines-host:3.C.host-type | category=plan | cancel-leaves=nothing -->

> 🚦 **Gate (plan · ensure-pipelines-host:3.C.host-type):** Top-level host-type prompt — pick Platform Host / Custom Host / PPAC manual / Manual export-import strategy / Cancel. Drives the rest of Phase 3 and Phase 4 routing.
>
> **Trigger:** Status `NoHost` AND no upstream `hostResolution.willProvision*` flag carries the answer.
> **Why we ask:** Auto-picking provisions an env (PE or Custom Host) without consent; PE is tenant-singleton and admin-non-deletable.
> **Cancel leaves:** Nothing — no provisioning fired yet.

<!-- gate: ensure-pipelines-host:3.C.env-pick | category=plan | cancel-leaves=nothing -->

> 🚦 **Gate (plan · ensure-pipelines-host:3.C.env-pick):** Sub-prompt under 3.C top-level option "Custom Host" — present the eligible-env list (capped at 5) with role labels (`dev env`, `source env`, `staging env`, `production env`) and the "Other (paste URL)" fallback. Fires only on the Custom-Host branch of the host-type menu.
>
> **Trigger:** User picked "Custom Host" in 3.C top-level.
> **Why we ask:** Auto-picking the wrong env routes pipelines through a host the user didn't intend.
> **Cancel leaves:** Nothing — no app install fired.

The prompt asks the user to pick the **host type** first (Platform Host, Custom Host, PPAC manual, or cancel). Picking Custom Host opens a sub-prompt for the install method (existing env vs. create-new). The Platform-Host path is the lowest-friction default and is presented first.

**Skip rule — caller already collected the answer.** When this skill is invoked from `setup-pipeline` and `docs/alm/last-pipeline.json` carries a `hostResolution` block populated by plan-alm Phase 2 Q4, inspect those flags before showing the prompt:

| Upstream signal | Action |
|---|---|
| `hostResolution.willProvisionPlatform === true` | Skip Phase 3.C entirely. Route directly to **Phase 4.0** (provision new Platform Host). The pre-call confirmation gate in 4.0 still runs — see 4.0 "Pre-call confirmation (NON-SKIPPABLE)" below. |
| `hostResolution.chosenEnvUrl` is a non-empty URL | Skip Phase 3.C entirely. Set `CHOSEN_ENV_URL = hostResolution.chosenEnvUrl`, route directly to **Phase 4.B** with that env (4.B step 1's "already chosen" path applies). Set `ACTION_TAKEN` per the existing routing table (`"user-installed-app-on-dev"` when origin matches `devEnvUrl`, else `"user-installed-app"`). |
| `hostResolution.willProvisionCustom === true` AND `chosenEnvUrl` empty | Skip Phase 3.C. Route directly to **Phase 4.A** (provision new Custom Host). The pre-call confirmation gate in 4.A still runs. |
| `hostResolution.willUsePpac === true` AND `chosenEnvUrl` empty | Skip Phase 3.C. Route directly to **Phase 4.C** (PPAC manual). |
| None of the above | Run Phase 3.C as written below. |

**Why this skip rule exists.** plan-alm Phase 2 Q4 NoHost branch presents the same host-type menu so the user makes the choice once, at planning time, with the rendered ALM plan in front of them. Re-prompting in 3.C at execution time would force a second answer to the same question and risks the agent treating one of the answers as authoritative and ignoring the other (the bug behind the trial-license-409 → wrong-env-fallback chain that surfaced on 2026-05-05). Whenever the upstream signal is present, trust it.

**Step 1: present the top-level host-type prompt.**

> "No Pipelines host bound to `{devEnvUrl}`. Which environment should host Pipelines?
>
> Pipelines lives in one env per tenant; pipelines, stages, and run history are stored there. Source envs deploy through it.
>
> 1. **Provision a Platform Host (recommended)** — Microsoft-managed Dataverse env auto-provisioned in your tenant home geo. Pipelines app pre-installed. Idempotent (safe to re-run). ~3–5 min.
>
> 2. **Set up a Custom Host** — Pipelines lives in a Dataverse env you control. We'll ask whether to use an existing env or create a brand-new dedicated one.
>
> 3. **Open PPAC and create one manually** — fallback if option 2 doesn't work for you.
>
> 4. **Cancel** — exit."

Top-level routing:

| Selection | Action |
|---|---|
| Option 1 | Phase 4.0 (with pre-call confirmation gate). `ACTION_TAKEN = "fast-path-platform-getorcreate"`. |
| Option 2 | Show the Custom-Host sub-prompt at Step 2 below. |
| Option 3 | Phase 4.C. `ACTION_TAKEN = "user-created-custom-ppac"`. |
| Option 4 | Exit. |

**Step 2: build the eligible-env list and apply role labels** (only needed when the user picks Option 2 → sub-option `a`; do this lazily after Option 2 is selected).

Take `RESOLUTION.candidates.eligibleForAppInstall[]` from Phase 2 (envs with Dataverse, caller has access, Pipelines NOT yet installed, sku ∈ default filter — `{Production, Sandbox}`; widen to `Production,Sandbox,Trial` via `--skus` for trial-license tenants).

For each entry, decorate with project-context labels at presentation time. Match by **URL origin** (lowercase, trailing slash stripped, path/query ignored). Multiple labels join with ` · `.

| Label | Source signal |
|---|---|
| `dev env` | The env URL the skill is running against (`devEnvUrl` from caller / `pac env who`) |
| `source env` | `sourceEnvironmentUrl` from `docs/alm/last-pipeline.json` (typically same env as dev) |
| `staging env` | `targetEnvironmentUrl` of any stage in `docs/alm/last-pipeline.json` whose stage name matches `/stag\|test\|uat/i` |
| `production env` | `targetEnvironmentUrl` of any stage whose name matches `/prod/i` |

Envs with no role match show without a label.

<!-- not-a-gate: methodology discussion (how Step 2 formats the prompt list) — no new prompt; the actual env-pick gate is the 3.C / 3.D host-type prompt elsewhere in Phase 3 -->

**Step 2a: rank and cap the eligible-env list.** `AskUserQuestion` becomes unusable past about 7–8 options. Apply a **5-env presentation cap** with role-aware ranking:

1. **Always-visible role-labeled envs** (highest priority): include any eligible env that carries a `dev env`, `source env`, `staging env`, or `production env` label from Step 2's role decoration. Dedupe by URL origin.
2. **Fill remaining slots up to 5** from the rest of the eligible list, in `list-tenant-envs.js`'s native order (name-hint pattern → admin-perms → lastModifiedTime).
3. **Always append** an "Other (paste URL)" entry as the last item.

Track the total eligible count separately — when `eligible.length > 5`, surface the gap inline.

**Step 2b: empty-list collapse.** If the eligible list has zero entries after the filters, first try widening the SKU filter to include Trial (re-invoke `ensure-pipelines-host-detect.js` with `--skus Production,Sandbox,Trial`). If still zero, **drop sub-option `a`** from the Custom-Host sub-prompt — present only `b` (create new) and `c` (Back). Print the SKU-filter detail so the user can override:

> *"No existing environments matched the SKU filter (Production, Sandbox, Trial). Run `--skus <comma-list>` to widen further, or pick `b` to create a brand-new env, or `c` to go back."*

**Step 2c: present the Custom-Host sub-prompt** (when user picks Option 2):

> "How would you like to set up the Custom Host?
>
> a. **Use an existing environment** — install the Pipelines app on it.{eligibleCountSuffix} Pick from your eligible envs:
>    - `{env[0].displayName}` (`{env[0].instanceApiUrl}`) — `{environmentSku}` — *{labels if any}*
>    - `{env[1].displayName}` (...)
>    - … (up to 5 entries)
>    - *Other (paste URL) — for any eligible env not on this short list*
>
>    *⚠ Sandbox-sku envs trigger a confirmation prompt before install.*
>
> b. **Create a brand-new dedicated env** — automated env-create with template `D365_ProjectHost`. Pipelines app pre-installed. Requires Global / Power Platform / Dynamics admin. ~5–10 min.
>
> c. **Back** — return to the top-level host-type menu."

`{eligibleCountSuffix}` substitution rules:
- `eligible.length <= 5` → empty string (no suffix; all envs visible).
- `eligible.length > 5` → ` Showing top 5 of {N}; the remaining {N-5} eligible env(s) can be reached via the "Other (paste URL)" entry.` (leading space).

When the user picks "Other (paste URL)", **pre-fill** the URL input with the environment list from `node "${PLUGIN_ROOT}/scripts/lib/list-environments.js"` (parses `pac env list` into JSON `{ displayName, environmentId, environmentUrl, uniqueName, active }`; the old `pac env list --output json` is invalid on current PAC CLI) so they can paste-or-pick from the inventory rather than typing a URL by hand.

**Test scenarios to verify when changing this prompt:**
- 0 eligible → sub-option `a` dropped (sub-prompt shows only `b` / `c`).
- 1–5 eligible → list all inline, no suffix.
- 6+ eligible with role-labeled envs (dev/staging/prod) present → all role-labeled envs surface first; remaining slots filled by ranking; suffix shows count gap.
- 6+ eligible with NO role-labeled envs → top 5 by ranking; su

…(truncated)
