agentleFS
aiappsgbbaiappsgbb/awesome-gbb/skills/foundry-vnet-deploy/SKILL.md

foundry-vnet-deploy

Skill8 starsChanged 12 days agoMITView on GitHub

Deploy Azure AI Foundry with **Agent Setup inside a private VNet** — the `15-private-network-standard-agent-setup` Bicep reference architecture. Supports new/existing VNets, resource reuse (CosmosDB / Storage / AI Search / private DNS zones), hosted-agent RBAC, App Insights, and spoke-side peering + APIM-DNS-zone link to a Citadel hub VNet. Read the full skill body for the guided interview, retry logic, and subnet sizing — do not deploy from this summary. USE FOR: foundry private vnet, network-secured foundry, agent injection, capability host, citadel hub peering, vnet-isolated citadel spoke, apim private dns zone link, central dns at scale, hub-spoke private dns, InvalidPrivateDnsZoneIds. DO NOT USE FOR: azd-based deploys (use threadlight-deploy or foundry-hosted-agents), public-network Foundry, APIM cross-resource (use foundry-cross-resource), tenant isolation (use azure-tenant-isolation), Citadel app-layer onboarding (use citadel-spoke-onboarding for APIM products + Foundry connection).

We split this skill into the rules it gives an agent

52 rules. About 0 are common, 30 are distinctive, and 22 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈30

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 22

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Before hosted artifact preparation, select the versioned hosted profile and complete its capability/permission feasibility gate.

    line 26
  2. Very specific:

    Follow these steps IN ORDER.

    line 115
  3. Very specific:

    Read back the project host's capabilityHostKind: Agents and provisioningState: Succeeded before registering a hosted version, including …

    line 158
  4. Very specific:

    Run az account show in that isolated context.

    line 181
  5. Distinctive:

    Ask who owns the VNet, private DNS zones, NSGs/routes and reverse peering, including their resource groups/subscriptions.

    line 187
  6. Distinctive:

    Subscription: Do they want to use the current subscription or change it?

    line 202
  7. Distinctive:

    If it needs to be created, ask for the name and location.

    line 204
  8. Distinctive:

    If it already exists, ask for the name.

    line 205
  9. Distinctive:

    Ask for the deployment region.

    line 209
  10. Distinctive:

    Ask whether the user has existing resources they want to reuse:

    line 286
  11. Very specific:

    Do you have an existing AI Search?: If yes → ask for aiSearchResourceId (full ARM format).

    line 288
  12. Very specific:

    Do you have an existing Storage Account?: If yes → ask for azureStorageAccountResourceId.

    line 289
  13. Very specific:

    Do you have an existing CosmosDB?: If yes → ask for azureCosmosDBAccountResourceId.

    line 290
  14. Distinctive:

    If none are provided, all of them will be created automatically.

    line 292
  15. Distinctive:

    If NO → all zones will be created automatically (6 zones).

    line 303
  16. Very specific:

    Ask for a list of AAD objectIds (you can get them with az ad signed-in-user show --query id -o tsv or az ad user show --id <upn> --query id …

    line 339
  17. Distinctive:

    If the user does not want to use it now, leave the list empty and it can be added manually later.

    line 340
  18. Very specific:

    If the spoke project will be onboarded into an AI Citadel Governance Hub as a spoke (see citadel-spoke-onboarding), this template can …

    line 360
  19. Very specific:

    If no, leave the four hub* / apimDnsZone* parameters empty in Step 9 and continue.

    line 365
  20. Distinctive:

    If yes, ask the next questions.

    line 366
  21. Very specific:

    If all three pass, the Foundry account in this VNet can now reach the Citadel hub APIM gateway, and you can run citadel-spoke-onboarding …

    line 390
  22. Very specific:

    When TEMPLATE_DIR=templates/basic-vnet, use the basic-vnet fork block after the IMPORTANT notes instead.

    line 396
  23. Very specific:

    If a value is empty (not provided by the user), use the empty string ''.

    line 475
  24. Distinctive:

    If using a new VNet without prefixes, leave the subnet prefixes empty (they will be calculated automatically).

    line 477
  25. Very specific:

    Use a descriptive name for the .bicepparam file (e.g.

    line 479
  26. Distinctive:

    After tenant isolation, use the approved resource names (the VNet can be in a different resource group within the selected subscription):

    line 557
  27. Distinctive:

    Ask the user whether to proceed with the deployment.

    line 596
  28. Distinctive:

    If they confirm, generate a fixed timestamp for the deployment and store it:

    line 598
  29. Distinctive:

    Run the deployment passing the timestamp as an explicit parameter:

    line 604
  30. Distinctive:

    If the deployment fails (frequently due to a timeout of the Account Capability Host), follow these steps to retry without duplicating …

    line 635
  31. Very specific:

    Inspect before any retry: Read the deployment operations from Step 10, run network_inventory.py from Step 9a for resources that exist, and …

    line 641
  32. Distinctive:

    Do not automatically purge the account, change VNet names, remove a SAL or delete resources by suffix: they may belong to an existing …

    line 653
  33. Very specific:

    If the same deterministic error recurs after two approved attempts, stop unchanged retries.

    line 667
  34. Distinctive:

    Do not bypass the template with manual writes to shared hosts or role grants.

    line 670
  35. Distinctive:

    After the deployment (successful or after completing the retry steps), run ALL these checks and present the results to the user as a status …

    line 674
  36. Very specific:

    control-plane only: Keep runtime DNS, TCP 443 and authenticated agent/tool checks from the actual caller network separate.

    line 678
  37. Distinctive:

    Use the runbook's paired ingress and real agent-egress checks.

    line 682
  38. Very specific:

    Verify: provisioningState = Succeeded, model and capacity match what was requested.

    line 722
  39. Very specific:

    Verify: provisioningState = Succeeded, identity.type = SystemAssigned, principalId is not null.

    line 732
  40. Distinctive:

    Follow the shared preflight.

    line 764
  41. Distinctive:

    Verify: 3 connections exist (CosmosDb, CognitiveSearch, AzureStorageAccount) with correct targets.

    line 775
  42. Very specific:

    Verify: 4 Private Endpoints, all with status Approved.

    line 796
  43. Distinctive:

    Use each zone's owning subscription/resource group and compare the exact VNet resource ID, not a resource-group substring.

    line 804
  44. Distinctive:

    Read the links for one approved zone at a time:

    line 807
  45. Very specific:

    If a link/group/record is missing, report the exact owner and discrepancy.

    line 822
  46. Very specific:

    Verify: publicNetworkAccess = Disabled on the 3 resources.

    line 842
  47. Distinctive:

    Verify the following exist:

    line 852
  48. Very specific:

    Verify: Built-in Data Contributor scoped to dbs/enterprise_memory.

    line 865
  49. Distinctive:

    Only claim the observed paths succeeded when their runtime evidence is present.

    line 890
  50. Distinctive:

    Ask the user how they will access the private resources of the VNet.

    line 898
  51. Distinctive:

    If the user has a VPN Gateway with Point-to-Site configured in another VNet, bidirectional peering with gateway transit is required so VPN …

    line 910
  52. Distinctive:

    Test-NetConnection -ComputerName "{apim}.azure-api.net" -Port 443

    line 1124

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Foundry VNet Deploy — Agent Setup inside a Private VNet

Before hosted artifact preparation, select the versioned hosted profile and complete its capability/permission feasibility gate. An existing VNet, capability host or ARM success does not prove serving configuration, runtime pull or result-reader access. Preserve the Basic/Standard topology and use the shared recovery contract instead of changing network/authentication after an uncertain result.

1. Goal

Guide the user step by step to deploy Azure AI Foundry with Agent in a private VNet using the Bicep files from the 15-private-network-standard-agent-setup reference project. The skill collects all required parameters, generates the .bicepparam file, and runs the deployment.

Optional scenario hint. When the user invokes the skill, they may pass a one-line scenario hint such as "new VNet in swedencentral" or "existing VNet with existing DNS zones". Use it to pre-fill defaults during the interview wherever it applies.

Day-2 lifecycle companion. The full workflow is for greenfield setup. For an existing private project missing only its Basic host, reuse only the canonical project-host module after read-only verification and explicit authorization; never replay the full deployment to repair one missing prerequisite. Follow the hosted deployment preflight. For other Day-2 capability host lifecycle operations (idempotent re-create, delete, soft-delete + purge, recovery), see foundry-caphost-lifecycle. Those destructive operations are not default troubleshooting.

2. Architecture being deployed

The deployment creates the following secure network architecture:

┌─────────────────────────────────────────────────────────┐
│                    Resource Group                        │
│                                                          │
│  ┌─────────────────────────────────────────────────────┐ │
│  │              Virtual Network (VNet)                  │ │
│  │                                                      │ │
│  │  ┌──────────────────┐  ┌──────────────────────────┐ │ │
│  │  │  Agent Subnet     │  │  Private Endpoint Subnet │ │ │
│  │  │  (delegated to    │  │                          │ │ │
│  │  │  Container Apps)  │  │  ● AI Services PE        │ │ │
│  │  │                   │  │  ● AI Search PE          │ │ │
│  │  │  Network          │  │  ● Storage PE (blob)     │ │ │
│  │  │  Injection for    │  │  ● CosmosDB PE (SQL)     │ │ │
│  │  │  AI Agents        │  │                          │ │ │
│  │  └──────────────────┘  └──────────────────────────┘ │ │
│  └─────────────────────────────────────────────────────┘ │
│                                                          │
│  ┌──────────────┐ ┌────────────┐ ┌───────────────────┐  │
│  │ AI Services  │ │ AI Search  │ │ Storage Account   │  │
│  │ (Foundry)    │ │ (standard) │ │ (StorageV2, ZRS)  │  │
│  │ ● SKU S0     │ │ ● disabled │ │ ● public access   │  │
│  │ ● public     │ │   public   │ │   disabled        │  │
│  │   disabled   │ │   access   │ │ ● shared key      │  │
│  │ ● network    │ │            │ │   disabled        │  │
│  │   injection  │ │            │ │                   │  │
│  └──────────────┘ └────────────┘ └───────────────────┘  │
│                                                          │
│  ┌──────────────┐ ┌────────────────────────────────────┐ │
│  │ CosmosDB     │ │ Private DNS Zones (6 zones)       │ │
│  │ (SQL API)    │ │ ● privatelink.services.ai.azure.. │ │
│  │ ● public     │ │ ● privatelink.openai.azure.com    │ │
│  │   disabled   │ │ ● privatelink.cognitiveservices.. │ │
│  │ ● local auth │ │ ● privatelink.search.windows.net  │ │
│  │   disabled   │ │ ● privatelink.blob.core.windows.. │ │
│  │              │ │ ● privatelink.documents.azure.com │ │
│  └──────────────┘ └────────────────────────────────────┘ │
│                                                          │
│  ┌──────────────────────────────────────────────────────┐ │
│  │ AI Foundry Account Capability Host                   │ │
│  │ ● capabilityHostKind: Agents                         │ │
│  │ ● customerSubnet → Agent Subnet (VNet injection)     │ │
│  │ ● Replaces manual createCapHost.sh                   │ │
│  └──────────────────────────────────────────────────────┘ │
│                                                          │
│  ┌──────────────────────────────────────────────────────┐ │
│  │ AI Foundry Project                                   │ │
│  │ ● System-Assigned Managed Identity                   │ │
│  │ ● Connections: CosmosDB, Storage, AI Search          │ │
│  │ ● Project Capability Host (Agents) with:             │ │
│  │   - vectorStoreConnections → AI Search               │ │
│  │   - storageConnections → Blob Storage                │ │
│  │   - threadStorageConnections → CosmosDB               │ │
│  │ ● Depends on Account Capability Host                 │ │
│  └──────────────────────────────────────────────────────┘ │
│                                                          │
│  Role Assignments (Project SMI):                         │
│  ● Storage Blob Data Contributor (account level)         │
│  ● Storage Blob Data Owner (container level, scoped)     │
│  ● Cosmos DB Operator (account level)                    │
│  ● Cosmos DB Built-in Data Contributor (enterprise_memory)│
│  ● AI Search Index Data Contributor                      │
│  ● AI Search Service Contributor                         │
│  └───────────────────────────────────────────────────────┘
└──────────────────────────────────────────────────────────┘

3. Workflow

Follow these steps IN ORDER. Use the ask_user tool for every question.

Step 0: Choose the template (Decision Guide)

This skill vendors two Foundry network-isolation templates. Pick one before the interview — they share Steps 1-8b/12/13 but differ on BYO resources and DNS.

You need…TemplateTEMPLATE_DIR
BYO Azure AI Search + Storage + Cosmos wired as project connections (vector store, thread storage, file storage)standard-agent (template 15)templates/standard-agent
Platform-managed storage — no BYO Search/Storage/Cosmos, smallest network surface, optional private ACRbasic-vnet (template 11)templates/basic-vnet

Ask the user:

  1. Do your agents need BYO Search / Storage / Cosmos connected to the project?
    • Yes / not sure / need vector search + custom thread storage → standard-agent.
    • No — platform-managed storage is fine → basic-vnet.

Set the working directory for every later deploy/verify command:

# standard:
TEMPLATE_DIR="templates/standard-agent"
# basic:
TEMPLATE_DIR="templates/basic-vnet"

What differs by fork (both keep the four awesome-gbb integrations — hosted-agent developer RBAC, App Insights project-MI roles, Citadel spoke peering, APIM DNS link):

Aspectstandard-agent (15)basic-vnet (11)
BYO resourcesAI Search + Storage + Cosmos (create or reuse)none (platform-managed)
Project connectionsCosmosDb, CognitiveSearch, AzureStorageAccount (+ appinsights)appinsights only
Private endpointsAI Services, Search, Storage(blob), Cosmos(SQL) (4)AI Services + Monitor PLS (2) + ACR (optional → 3)
Private DNS zones6 (services.ai, openai, cognitiveservices, search, blob, documents)3 app (services.ai, openai, cognitiveservices) + azurecr.io (if ACR) + 4 monitor zones
Optional private ACRnoyes (enableContainerRegistry, default true; developerIpCidr)
Account capability hostyes (accountCapHost)project caphost only (projectCapHost = caphostproj)
Role assignments6 (project-MI over BYO) + optional developer/telemetrytelemetry (LAW Reader + Azure AI User) + optional developer + ACR AcrPull
Deploy time45-90 min30-60 min (no BYO wiring)

Basic is platform-managed backing storage, not absence of a project host. Read back the project host's capabilityHostKind: Agents and provisioningState: Succeeded before registering a hosted version, including when using raw SDK or brownfield azd deploy. If the inventory is empty, only the explicitly authorized add-project-capability-host.bicep module is needed against the existing account/project. Do not add the Standard account host to Basic. Existing BYO connections mean stop and resolve mode, not overwrite them with Basic defaults.

Steps 7 (BYO existing resources) and 8 (6-zone DNS map) apply to standard-agent only. On basic-vnet, skip Step 7 entirely; in Step 8 the DNS map uses the basic zone set (existingDnsZones = 3 app zones + optional azurecr.io; existingMonitorDnsZones = 4 monitor zones), and you additionally ask about the optional private ACR (enableContainerRegistry, developerIpCidr).

Step 1: Verify prerequisites

Before starting, verify:

  1. Apply azure-tenant-isolation before the first Azure command in each shell: both isolated CLI config directories and the approved tenant/subscription must be explicit. Never log in or switch the global default as a diagnostic.
  2. Run az account show in that isolated context; confirm the intended target with the user. If authentication is missing, use the tenant-isolation workflow.
  3. Establish the network model and execution location before selecting parameters. The two vendored templates cover BYO VNet, not Managed VNet. If Managed VNet is requested, stop for a separately approved design rather than treating its outbound rules as interchangeable with these templates.
  4. Ask who owns the VNet, private DNS zones, NSGs/routes and reverse peering, including their resource groups/subscriptions. Reusing resources does not authorize editing them. Collect existing IaC and identify additive changes; keep the vendored templates and their Citadel interfaces intact.
  5. Record where each caller runs (agent runtime, SDK client, browser/Playground) and how it reaches private DNS and TCP 443. A management-plane check from outside the VNet cannot establish those runtime paths. A Foundry private endpoint covers ingress, not agent egress. Use the runbook's caller/path intake to separate hosted code from platform tool/data-proxy calls.

Step 2: Subscription and Resource Group

Ask the user:

  1. Subscription: Do they want to use the current subscription or change it? If they want to change, ask for the subscription ID.
  2. Resource Group: Does the resource group already exist or does it need to be created?
    • If it needs to be created, ask for the name and location.
    • If it already exists, ask for the name.

Step 3: Location

Ask for the deployment region. The allowed regions are:

  • westus, eastus, eastus2, japaneast, francecentral, spaincentral, uaenorth
  • southcentralus, italynorth, germanywestcentral, brazilsouth, southafricanorth
  • australiaeast, swedencentral, canadaeast, westeurope, westus3, uksouth, southindia
  • koreacentral, polandcentral, switzerlandnorth, norwayeast (Class B and C only)

Present the options as a list and offer swedencentral as the recommended one.

Step 4: AI service and project name

Ask:

  1. aiServices: Prefix for the AI Services resource (default: foundry). A unique suffix will be appended automatically.
  2. firstProjectName: Project name (default: project).
  3. displayName: Visible project name (default: same as the project name).
  4. projectDescription: Project description (default: A project for the AI Foundry account with network secured deployed Agent).
  5. accountCapHost: Name of the account-level capability host (default: caphostacct). This resource sets customerSubnet for the agent runtime network injection.

Step 5: Model configuration

Ask:

  1. modelName: Name of the model to deploy (default: gpt-4.1). Common options: gpt-4.1, gpt-4o, gpt-4o-mini, gpt-4.1-mini, gpt-4.1-nano.
  2. modelFormat: Model provider (default: OpenAI).
  3. modelVersion: Model version. Depends on the chosen model.
  4. modelSkuName: Deployment SKU (default: GlobalStandard). Options: GlobalStandard, Standard, ProvisionedManaged.
  5. modelCapacity: TPM (tokens per minute) in thousands (default: 30 = 30K TPM).

Step 6: Network configuration (VNet)

Ask the user:

Are you going to use an existing VNet or create a new one?

Subnet sizing (agent injection). The agent subnet is delegated to Microsoft.App/environments and consumes ~1 IP per ~10 running agent pods. The platform caps concurrent agent sessions at 50 per subscription per region — that ceiling does not scale with a bigger subnet. A larger subnet buys project density (~250 projects at low traffic, as few as ~25 at full scale) and upgrade/scale headroom, not more sessions.

Agent subnet CIDRUsable IPsConcurrent sessionsUse when
/2425150 (platform cap) + upgrade bufferProduction default (Microsoft-recommended)
/2512350 (platform cap)Buffer between /26 and /24
/2659~50 — minimum to reach the capSmallest that supports the full 50
/2727~17Dev/test only — minimum, risky

Rules: RFC-1918 only (10/8, 172.16/12, 192.168/16) — the CGNAT 100.64.0.0/10 range is not supported (routing failures). Avoid 172.17.0.0/16 (reserved by Docker bridge). Target < 80 % utilization. Prompt-agent revisions do not consume subnet IPs; hosted-agent revisions do (100 active / 1000 total per agent name; ~200 hosted agents per Foundry instance). The PE subnet only needs one IP per private endpoint (a /27 is plenty). Full IP math and exhaustion symptoms: references/agent-networking.md.

Option A: New VNet

Ask:

  1. vnetName: VNet name (default: agent-vnet).
  2. vnetAddressPrefix: Address space (default: 192.168.0.0/16). Also supports 10.x.x.x/16 or 172.16.x.x/16.
  3. agentSubnetPrefix: CIDR of the agent subnet (default: automatically calculated as the first /24 of the address space).
  4. peSubnetPrefix: CIDR of the private endpoint subnet (default: automatically calculated as the second /24).
  5. agentSubnetName: Name of the agent subnet (default: agent-subnet).
  6. peSubnetName: Name of the PE subnet (default: pe-subnet).

Option B: Existing VNet

Ask:

  1. existingVnetResourceId: Full resource ID of the VNet. Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{name}.
  2. agentSubnetName: Name of the agent subnet (will be created if it does not exist). IMPORTANT: This subnet needs delegation to Microsoft.App/environments.
  3. peSubnetName: Name of the private endpoint subnet (will be created if it does not exist).
  4. agentSubnetPrefix: CIDR of the agent subnet (mandatory if the subnet does not exist). WARNING: Must not overlap with existing subnets.
  5. peSubnetPrefix: CIDR of the PE subnet (mandatory if the subnet does not exist).

Step 7: Existing resources (optional)

Ask whether the user has existing resources they want to reuse:

  1. Do you have an existing AI Search? If yes → ask for aiSearchResourceId (full ARM format).
  2. Do you have an existing Storage Account? If yes → ask for azureStorageAccountResourceId.
  3. Do you have an existing CosmosDB? If yes → ask for azureCosmosDBAccountResourceId.

If none are provided, all of them will be created automatically. The new resources are created with:

  • AI Search: Standard SKU, public access disabled, local auth enabled with AAD.
  • Storage: StorageV2, ZRS (or GRS in southindia/westus), public access disabled, shared key disabled.
  • CosmosDB: Global Document DB, Session consistency, public access disabled, local auth disabled.

Step 8: Private DNS zones (optional)

Ask:

Do you have existing private DNS zones that you want to reuse?

If NO → all zones will be created automatically (6 zones). Continue to step 9.

If YES → ask:

  1. dnsZonesSubscriptionId: Subscription where the DNS zones are located (leave empty if it is the same as the deployment).
  2. For each zone, ask whether it exists and which resource group it is in:
    • privatelink.services.ai.azure.com
    • privatelink.openai.azure.com
    • privatelink.cognitiveservices.azure.com
    • privatelink.search.windows.net
    • privatelink.blob.core.windows.net
    • privatelink.documents.azure.com

The format is an object where each key is the zone name and the value is the resource group (empty = create a new one).

Central DNS at scale (hub-and-spoke). When the spoke is deployed into an enterprise hub-and-spoke topology, the 6 zones above are typically owned by the platform team in a separate subscription (often the connectivity hub). The dnsZonesSubscriptionId parameter combined with the per-zone resource-group map in existingDnsZones is exactly the pattern described in Microsoft's Cloud Adoption Framework — see Private Link and DNS integration at scale for the canonical reference architecture. The deployment principal needs Private DNS Zone Contributor on each zone in the hub subscription to create the VNet links; without this you will hit InvalidPrivateDnsZoneIds at deploy time.

  • For DNS troubleshooting, including InvalidPrivateDnsZoneIds, use foundry-network-runbook. With corporate/custom DNS, record the resolver path as well as zone ownership. On-premises conditional forwarders use the recommended public service namespaces toward an Azure DNS Private Resolver inbound endpoint or existing Azure-hosted forwarder, not directly toward 168.63.129.16. A VNet link or VPN connection alone does not configure client DNS.

Step 8b: Hosted agent developers (optional but recommended)

Users / service principals that are going to create hosted agents in this Foundry need:

  • Managed Identity Operator on the Foundry account
  • Network Contributor on the agent injection subnet

Without these permissions, hosted agent creation will fail with 403 errors when provisioning the MIs or NICs. The template can assign them automatically.

Ask:

  1. Which users / groups / SPs are going to create hosted agents in this Foundry?
    • Ask for a list of AAD objectIds (you can get them with az ad signed-in-user show --query id -o tsv or az ad user show --id <upn> --query id -o tsv).
    • If the user does not want to use it now, leave the list empty and it can be added manually later.
  2. Principal type (User / Group / ServicePrincipal). Default User. All IDs must be of the same type (run the module twice if you need to mix types).

Step 8c: Application Insights + Log Analytics (recommended)

The official hosted agent permissions doc lists App Insights + Log Analytics Workspace as required resources:

  • They enable agent traces, logs and metrics
  • Required for the evaluations feature
  • The template creates them + creates an appinsights connection in the project + assigns to the project MI:
    • Log Analytics Reader on the workspace
    • Azure AI User on the account (recommended by the doc so the MI can infer via the project endpoint)

They usually do not need user input; names are auto-generated unless the user wants to customize them.

Optional question:

  1. Do you want custom names for the Log Analytics workspace and App Insights? If not, they are auto-generated with a unique suffix.

Step 8d: Citadel hub integration (optional)

If the spoke project will be onboarded into an AI Citadel Governance Hub as a spoke (see citadel-spoke-onboarding), this template can create the spoke-side network plumbing in the same deployment. Otherwise skip to Step 9.

Ask:

  1. Will this Foundry be a Citadel hub spoke? (yes/no — default no).
    • If no, leave the four hub* / apimDnsZone* parameters empty in Step 9 and continue. Citadel onboarding can also be added later via a re-deploy with the same deploymentTimestamp.
    • If yes, ask the next questions.
  2. hubVnetResourceId — full ARM ID of the Citadel hub VNet. Format: /subscriptions/{hubSub}/resourceGroups/{hubRg}/providers/Microsoft.Network/virtualNetworks/{hubVnet}.
    • The deployment will create only the spoke→hub peering (your RBAC). The hub→spoke reverse peering is hub-team RBAC; the deployment will emit a one-line az network vnet peering create in the hubReversePeeringCommand output for them to run.
    • ⚠️ Address-space requirement: spoke and hub VNets must NOT overlap. Re-validate the values from Step 6 against the hub VNet's address space before continuing.
  3. apimDnsZoneResourceId (optional) — full ARM ID of the existing privatelink.azure-api.net private DNS zone (typically owned by the hub team). Format: /subscriptions/{hubSub}/resourceGroups/{hubDnsRg}/providers/Microsoft.Network/privateDnsZones/privatelink.azure-api.net.
    • Required when the Citadel hub APIM uses a private endpoint (most common). Skip when APIM is reachable on a public endpoint.
    • The deployment will link the zone to the spoke VNet so the agent resolves {apim}.azure-api.net to its private IP.
  4. hubPeeringName (optional, default peering-to-hub) — friendly name for the spoke-side peering.
  5. apimDnsZoneLinkName (optional, default foundry-spoke-link) — VNet-link name on the DNS zone (must be unique within the zone).

Pre-flight (run after Step 11 verification, before invoking citadel-spoke-onboarding):

# 1. Both peerings must be Connected
az network vnet peering show --resource-group {rg} --vnet-name {vnetName} `
  --name {hubPeeringName} --query peeringState -o tsv     # → "Connected"

# 2. DNS resolution from inside the spoke VNet (run from a peered VM/Bastion):
Resolve-DnsName "{apim}.azure-api.net"                    # must resolve to a 10.x / 192.168.x / 172.16.x private IP

# 3. End-to-end TCP reachability:
Test-NetConnection -ComputerName "{apim}.azure-api.net" -Port 443

If all three pass, the Foundry account in this VNet can now reach the Citadel hub APIM gateway, and you can run citadel-spoke-onboarding against the project to inject the APIM products + Foundry connection.

Step 9: Generate the .bicepparam file

With all the data collected, generate the main.bicepparam file. This skill vendors two templates (Step 0): the block below is for standard-agent (template 15). When TEMPLATE_DIR=templates/basic-vnet, use the basic-vnet fork block after the IMPORTANT notes instead. The deploy-{rg}.bicepparam naming + the "never overwrite main.bicepparam" rule apply to both.

standard-agent (template 15) — BYO Search / Storage / Cosmos:

using './main.bicep'

param location = '{location}'
param aiServices = '{aiServices}'
param modelName = '{modelName}'
param modelFormat = '{modelFormat}'
param modelVersion = '{modelVersion}'
param modelSkuName = '{modelSkuName}'
param modelCapacity = {modelCapacity}
param firstProjectName = '{firstProjectName}'
param projectDescription = '{projectDescription}'
param displayName = '{displayName}'
param peSubnetName = '{peSubnetName}'
param accountCapHost = '{accountCapHost}'

// Existing resources
param existingVnetResourceId = '{existingVnetResourceId}'
param vnetName = '{vnetName}'
param agentSubnetName = '{agentSubnetName}'
param aiSearchResourceId = '{aiSearchResourceId}'
param azureStorageAccountResourceId = '{azureStorageAccountResourceId}'
param azureCosmosDBAccountResourceId = '{azureCosmosDBAccountResourceId}'

// DNS configuration
param dnsZonesSubscriptionId = '{dnsZonesSubscriptionId}'
param existingDnsZones = {
  'privatelink.services.ai.azure.com': '{rg_or_empty}'
  'privatelink.openai.azure.com': '{rg_or_empty}'
  'privatelink.cognitiveservices.azure.com': '{rg_or_empty}'
  'privatelink.search.windows.net': '{rg_or_empty}'
  'privatelink.blob.core.windows.net': '{rg_or_empty}'
  'privatelink.documents.azure.com': '{rg_or_empty}'
}

param dnsZoneNames = [
  'privatelink.services.ai.azure.com'
  'privatelink.openai.azure.com'
  'privatelink.cognitiveservices.azure.com'
  'privatelink.search.windows.net'
  'privatelink.blob.core.windows.net'
  'privatelink.documents.azure.com'
]

// Network configuration
param vnetAddressPrefix = '{vnetAddressPrefix}'
param agentSubnetPrefix = '{agentSubnetPrefix}'
param peSubnetPrefix = '{peSubnetPrefix}'

// Hosted-agent developer RBAC (optional). Object IDs that will get
// 'Managed Identity Operator' on the account and 'Network Contributor'
// on the agent subnet. Leave empty to skip.
param agentDeveloperPrincipalIds = [
  // '00000000-0000-0000-0000-000000000000'
]
param agentDeveloperPrincipalType = 'User'

// Application Insights + Log Analytics workspace (REQUIRED by hosted agent permissions doc).
// Leave empty to auto-name; project gets an `appinsights` connection and the
// project MI receives Log Analytics Reader + Azure AI User on the account.
param logAnalyticsWorkspaceName = ''
param appInsightsName = ''

// Citadel hub integration (optional). Set to non-empty values when the spoke
// will be onboarded as a Citadel hub spoke (Step 8d above + citadel-spoke-onboarding).
// Leave empty to skip — existing flows are unchanged.
param hubVnetResourceId = '{hubVnetResourceId}'
param hubPeeringName = '{hubPeeringName}'           // default: 'peering-to-hub'
param apimDnsZoneResourceId = '{apimDnsZoneResourceId}'
param apimDnsZoneLinkName = '{apimDnsZoneLinkName}' // default: 'foundry-spoke-link'

IMPORTANT:

  • If a value is empty (not provided by the user), use the empty string ''.
  • The dnsZoneNames parameter must always include the 6 zones.
  • If using a new VNet without prefixes, leave the subnet prefixes empty (they will be calculated automatically).
  • DO NOT include deploymentTimestamp in the .bicepparam file. This parameter is always passed via CLI (--parameters deploymentTimestamp=...) so it can be reused on retries without modifying the file.
  • Use a descriptive name for the .bicepparam file (e.g. deploy-{resourceGroup}.bicepparam) so as not to overwrite the project's original main.bicepparam.

basic-vnet fork. When TEMPLATE_DIR=templates/basic-vnet, generate the param file below instead of the standard block above (no BYO resource IDs, no 6-zone dnsZoneNames). Before writing existingDnsZones / existingMonitorDnsZones / enableContainerRegistry values, the key names below are the vendored templates/basic-vnet/main.bicep defaults — match them exactly; do not invent variants.

basic-vnet (template 11) — platform-managed storage, optional private ACR:

using './main.bicep'

param location = '{location}'
param aiServices = '{aiServices}'
param modelName = '{modelName}'
param modelFormat = '{modelFormat}'
param modelVersion = '{modelVersion}'
param modelSkuName = '{modelSkuName}'
param modelCapacity = {modelCapacity}
param firstProjectName = '{firstProjectName}'
param projectDescription = '{projectDescription}'
param displayName = '{displayName}'
param projectCapHost = '{projectCapHost}'   // default 'caphostproj'

// Network (new or existing VNet — same questions as Step 6)
param existingVnetResourceId = '{existingVnetResourceId}'
param vnetName = '{vnetName}'
param agentSubnetName = '{agentSubnetName}'
param peSubnetName = '{peSubnetName}'
param vnetAddressPrefix = '{vnetAddressPrefix}'
param agentSubnetPrefix = '{agentSubnetPrefix}'
param peSubnetPrefix = '{peSubnetPrefix}'

// Optional private Azure Container Registry (basic-vnet only; default enabled)
param enableContainerRegistry = {true_or_false}
param developerIpCidr = '{developer_ip_cidr_or_empty}'

// DNS (basic zone set). Leave RG empty to create; set RG to reuse hub-owned zones.
param dnsZonesSubscriptionId = '{dnsZonesSubscriptionId}'
param existingDnsZones = {
  'privatelink.services.ai.azure.com': '{rg_or_empty}'
  'privatelink.openai.azure.com': '{rg_or_empty}'
  'privatelink.cognitiveservices.azure.com': '{rg_or_empty}'
  'privatelink.azurecr.io': '{rg_or_empty}'          // only relevant if enableContainerRegistry
}
param existingMonitorDnsZones = {
  'privatelink.monitor.azure.com': '{rg_or_empty}'
  'privatelink.oms.opinsights.azure.com': '{rg_or_empty}'
  'privatelink.ods.opinsights.azure.com': '{rg_or_empty}'
  'privatelink.agentsvc.azure-automation.net': '{rg_or_empty}'
}

// Hosted-agent developer RBAC (optional)
param agentDeveloperPrincipalIds = [
  // '00000000-0000-0000-0000-000000000000'
]
param agentDeveloperPrincipalType = 'User'

// Citadel hub integration (optional — same semantics as standard-agent)
param hubVnetResourceId = '{hubVnetResourceId}'
param hubPeeringName = '{hubPeeringName}'            // default 'peering-to-hub'
param apimDnsZoneResourceId = '{apimDnsZoneResourceId}'
param apimDnsZoneLinkName = '{apimDnsZoneLinkName}'  // default 'foundry-spoke-link'

Step 9a: Read-only network inventory

For an existing Foundry account and existing agent subnet, run the canonical helper before proposing reuse, after a failed deployment, and again during Step 11. For greenfield resources not yet created, record this check as pending until they exist; do not create resources just to make the probe run.

Canonical referencePurpose
references/python/network_inventory.pyTenant-checked, read-only account posture, account-side PE approvals and agent-subnet delegation/SAL inventory.

After tenant isolation, use the approved resource names (the VNet can be in a different resource group within the selected subscription):

python3 "$SKILL_DIR/references/python/network_inventory.py" \
  --resource-group "$RG" --account "$ACCOUNT" \
  --vnet-resource-group "$VNET_RG" --vnet "$VNET" --subnet "$AGENT_SUBNET"

Set SKILL_DIR to this skill's absolute directory. The helper requires Python 3 and an already authenticated Azure CLI; it installs nothing. It returns 0 when these limited control-plane checks have no blockers, 1 for observed private-posture blockers, or 2 when inspection is unavailable (auth, missing resource, permissions, timeout or unexpected response). A failed read is not an empty successful inventory. All reads use the approved subscription explicitly after verifying CLI tenant/subscription; no login, role grant or network mutation is performed. The report omits resource IDs and credentials.

runtime_connectivity: NOT_TESTED is intentional even on exit 0. Account-side PE approval does not prove the PE is on the intended route, DNS is linked, or agent/tool authentication works. The inventory does not prove that the account is bound to the supplied subnet. A SAL can be normal for an existing deployment; its presence or absence does not authorize subnet reuse or purge. Review ownership, the chosen template and the existing capability hosts separately. Cross-subscription DNS, peerings and BYO datastore roles still require the specific checks in Steps 8, 11 and 12D; this helper does not certify them.

Step 10: Confirm and deploy

  1. Show a complete summary of the configuration to the user, including:
    • Subscription and Resource Group
    • Region
    • AI service and project name
    • Model and capacity
    • VNet type (new/existing) and subnets
    • Reused existing resources
    • DNS zones (new/existing)
    • Citadel hub integration (if Step 8d was completed): hub VNet ARM ID, APIM DNS zone ARM ID, peering name. Otherwise: "(none — local Foundry only)".
  2. Ask the user whether to proceed with the deployment.
  3. If they confirm, generate a fixed timestamp for the deployment and store it:
    $deployTimestamp = Get-Date -Format 'yyyyMMddHHmmss'
    This timestamp will be reused if the deployment has to be retried.
  4. Run the deployment passing the timestamp as an explicit parameter:

    az deployment group create \
      --resource-group {resourceGroup} \
      --template-file "$TEMPLATE_DIR/main.bicep" \
      --parameters {bicepparam_file} \
      --parameters deploymentTimestamp={deployTimestamp} \
      --name "foundry-vnet-{deployTimestamp}"

    ⚠️ CRITICAL — Timestamp idempotency The Bicep uses uniqueString(resourceGroup().id + deploymentTimestamp) to generate a unique suffix (uniqueSuffix) that is appended to ALL resource names (AI Services, CosmosDB, Storage, AI Search, Project). If the timestamp changes between attempts, a different suffix will be generated and DUPLICATE resources will be created instead of retrying the existing ones. That is why it is mandatory to fix the timestamp and reuse it on any retry.

  5. The deployment can take 45-90 minutes. The slowest step is the Account Capability Host (network injection / Container Apps Environment) which can take 30-60+ minutes. Monitor the progress with:
    az deployment operation group list --resource-group {resourceGroup} \
      --name "foundry-vnet-{deployTimestamp}" \
      --query "[].{name:properties.targetResource.resourceName, state:properties.provisioningState}" -o table
  6. If the deployment fails, follow the retry procedure in Step 10b.

Step 10b: Safe retry (anti-duplication)

If the deployment fails (frequently due to a timeout of the Account Capability Host), follow these steps to retry without duplicating resources:

  1. Reuse the SAME timestamp ($deployTimestamp) as the original attempt. This is the most important thing to avoid duplication.
  2. Inspect before any retry. Read the deployment operations from Step 10, run network_inventory.py from Step 9a for resources that exist, and use the capability-host GET checks in Step 11.4 for the selected template. Azure can complete an operation after a client timeout. Do not use PUT as an existence probe, infer success from a 409, or launch another write while provisioning is still in progress. A 403 or unavailable inventory is not evidence that a resource is absent.
  3. Review the failure and get approval for the proposed recovery. Preserve the original timestamp, parameter file and resource identities. If the subnet is still associated with an earlier deployment, stop and use foundry-caphost-lifecycle to review ownership and teardown implications. Do not automatically purge the account, change VNet names, remove a SAL or delete resources by suffix: they may belong to an existing workload. A genuinely new VNet or teardown is a new approved plan, not an implicit retry.
  4. Re-run the full deployment with the original timestamp:
    az deployment group create \
      --resource-group {resourceGroup} \
      --template-file "$TEMPLATE_DIR/main.bicep" \
      --parameters {bicepparam_file} \
      --parameters deploymentTimestamp={deployTimestamp} \
      --name "foundry-vnet-retry-{deployTimestamp}"
  5. If the same deterministic error recurs after two approved attempts, stop unchanged retries. Report the failed resource type, provisioning state and error code, and hand off capability-host repair to foundry-caphost-lifecycle. Do not bypass the template with manual writes to shared hosts or role grants.

Step 11: Post-deployment verification

After the deployment (successful or after completing the retry steps), run ALL these checks and present the results to the user as a status table.

Repeat Step 9a against the deployed account/subnet. Label this result control-plane only. Keep runtime DNS, TCP 443 and authenticated agent/tool checks from the actual caller network separate. If that network is unavailable, report NOT_TESTED, not a successful private deployment; never enable public access or loosen network policy to make a validation pass. Use the runbook's paired ingress and real agent-egress checks. An external 403 must be attributable to network restrictions, not invalid auth; an agent's tool call needs correlated destination evidence, not a VM-only probe.

basic-vnet verification differences. On templates/basic-vnet:

  • Private Endpoints: expect 2 (AI Services + Monitor PLS) or 3 when enableContainerRegistry=true (adds the ACR PE). Not 4.
  • DNS VNet links: 3 app zones + azurecr.io (if ACR) + 4 monitor zones.
  • Capability host: project caphost only (caphostproj); there is no customer-created account capability host to require (skip that prerequisite; still inspect existing account defaults without mutating them).
  • Project connections: expect appinsights only (no CosmosDb / CognitiveSearch / AzureStorageAccount).
  • Role assignments: project-MI Log Analytics Reader (LAW) + Azure AI User (account); ACR AcrPull for legacy registry mode or Container Registry Repository Reader for ABAC mode (verify repository conditions; do not change registry mode); no Storage/Cosmos/Search roles.
  • Skip Steps 11.9 (BYO public-access) and 11.10 (BYO roles) — no BYO resources.

11.1 — AI Services Account

az rest --method GET \
  --url "https://management.azure.com/subscriptions/{subId}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{accountName}?api-version=2025-04-01-preview" \
  --query "{name:name, state:properties.provisioningState, publicAccess:properties.publicNetworkAccess, sku:sku.name, networkInjections:properties.networkInjections}" -o json

Verify:

  • provisioningState = Succeeded
  • publicNetworkAccess = Disabled
  • networkInjections[0].scenario = agent and points to the correct subnet

11.2 — Model deployment

az rest --method GET \
  --url "https://management.azure.com/subscriptions/{subId}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{accountName}/deployments?api-version=2025-04-01-preview" \
  --query "value[].{name:name, model:properties.model.name, version:properties.model.version, sku:sku.name, capacity:sku.capacity, state:properties.provisioningState}" -o table

Verify: provisioningState = Succeeded, model and capacity match what was requested.

11.3 — Project + Identity

az rest --method GET \
  --url "https://management.azure.com/subscriptions/{subId}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{accountName}/projects/{projectName}?api-version=2025-04-01-preview" \
  --query "{name:name, state:properties.provisioningState, identityType:identity.type, principalId:identity.principalId}" -o json

Verify: provisioningState = Succeeded, identity.type = SystemAssigned, principalId is not null.

11.4 — Capability Hosts

Account Capability Host — required for standard-agent, not basic-vnet. Verification is read-only. A read failure is inconclusive, never permission to PUT:

az rest --method GET \
  --url "https://management.azure.com/subscriptions/{subId}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{accountName}/capabilityHosts?api-version=2025-06-01"

Project Capability Host — both modes. List first; use the actual returned host name (projectCapHost, not a hardcoded caphost) for an exact GET:

az rest --method GET \
  --url "https://management.azure.com/subscriptions/{subId}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{accountName}/projects/{projectName}/capabilityHosts?api-version=2025-06-01"

For each returned host, use its full ARM ID from the inventory for the exact readback (account host only when present, project host in both private modes):

az rest --method GET \
  --url "https://management.azure.com{returnedHostId}?api-version=2025-06-01"

Verify:

  • provisioningState = Succeeded
  • capabilityHostKind = Agents
  • Standard: connection names match the approved existing BYO configuration.
  • Basic: no BYO connection arrays; platform-managed stores.
  • Empty inventory blocks registration. Failed/incompatible hosts are preserved. Follow the shared preflight; account success alone does not pass this check.

11.5 — Project Connections

az rest --method GET \
  --url "https://management.azure.com/subscriptions/{subId}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{accountName}/projects/{projectName}/connections?api-version=2025-04-01-preview" \
  --query "value[].{name:name, category:properties.category, target:properties.target}" -o table

Verify: 3 connections exist (CosmosDb, CognitiveSearch, AzureStorageAccount) with correct targets.

11.6 — VNet and Subnets

az network vnet show --resource-group {rg} --name {vnetName} \
  --query "{name:name, addressSpace:addressSpace.addressPrefixes[0], subnets:subnets[].{name:name, prefix:addressPrefix, delegation:delegations[0].serviceName}}" -o json

Verify:

  • Address space is correct
  • agent-subnet delegated to Microsoft.App/environments
  • pe-subnet without delegation

11.7 — Private Endpoints

az network private-endpoint list --resource-group {rg} \
  --query "[].{name:name, status:privateLinkServiceConnections[0].privateLinkServiceConnectionState.status}" -o table

Verify: 4 Private Endpoints, all with status Approved.

11.8 — DNS Zone VNet Links

For each zone in the selected template, verify the intended resolver path: Standard uses the six app/datastore zones; Basic uses the map above. With Azure-provided DNS, verify links for the actual caller VNets. With a centralized resolver/forwarder, verify its VNet links and the callers' forwarding path. Use each zone's owning subscription/resource group and compare the exact VNet resource ID, not a resource-group substring.

Read the links for one approved zone at a time:

az network private-dns link vnet list `
  --subscription {dnsZonesSubscriptionId} --resource-group {dnsZonesRg} `
  --zone-name {zoneName} --output json

A successful link is not enough: inspect the intended PE's DNS zone group and each zone's actual A records, then compare the full CNAME/A answers from the caller with the PE's current private addresses. Check all three Foundry FQDNs (services.ai.azure.com, openai.azure.com, cognitiveservices.azure.com) and required tool/dependency FQDNs. An arbitrary private IP or an empty zone does not pass.

If a link/group/record is missing, report the exact owner and discrepancy. Obtain approval for an additive change through the existing IaC/central DNS process; do not create a competing zone or mutate shared links as a probe.

11.9 — Private resources (public access disabled)

# CosmosDB
az cosmosdb show --name {cosmosName} --resource-group {rg} \
  --query "{name:name, publicAccess:publicNetworkAccess}" -o json

# AI Search
az search service show --name {searchName} --resource-group {rg} \
  --query "{name:name, publicAccess:publicNetworkAccess}" -o json

# Storage
az storage account show --name {storageName} --resource-group {rg} \
  --query "{name:name, publicAccess:publicNetworkAccess, allowSharedKey:allowSharedKeyAccess}" -o json

Verify: publicNetworkAccess = Disabled on the 3 resources. Storage additionally: allowSharedKeyAccess = false.

11.10 — Role Assignments (6 total)

ARM RBAC (5 assignments):

az role assignment list --assignee {principalId} --all \
  --query "[].{role:roleDefinitionName, scope:scope}" -o table

Verify the following exist:

  1. Storage Blob Data Contributor (scope: storage account)
  2. Storage Blob Data Owner (scope: storage account, with ABAC condition)
  3. Cosmos DB Operator (scope: cosmos account)
  4. Search Index Data Contributor (scope: search service)
  5. Search Service Contributor (scope: search service)

CosmosDB SQL Role (1 assignment):

az cosmosdb sql role assignment list --account-name {cosmosName} --resource-group {rg} \
  --query "[?principalId=='{principalId}'].{scope:scope}" -o table

Verify: Built-in Data Contributor scoped to dbs/enterprise_memory.

11.11 — Summary and result

Present a summary table with observed status, time and evidence for each check. The counts below describe Standard; adapt to the selected Basic configuration, marking non-applicable components with a reason. Use NOT_TESTED for unavailable checks. The first ten rows are control-plane observations, not runtime proof:

ComponentStatusDetail
AI Services✅/❌name, SKU, network injection
Model✅/❌name, version, SKU, capacity
Project✅/❌name, identity
Account CapHost✅/❌kind, subnet
Project CapHost✅/❌kind, connections
VNet + Subnets✅/❌address space, delegation
Private Endpoints (4)✅/❌status Approved
DNS VNet Links (6)✅/❌all linked
Private resources (3)✅/❌publicAccess Disabled
Role Assignments (6)✅/❌all present
Private ingress✅/❌/NOT_TESTEDactual caller, expected PE DNS, TLS and authenticated response
Public-route rejection✅/❌/NOT_TESTEDapproved external caller, valid auth, network-specific rejection
Agent/tool egress✅/❌/NOT_TESTEDactual invocation correlated with destination evidence; proxy/SNAT accounted for

Only claim the observed paths succeeded when their runtime evidence is present. This does not certify every tool or deny-all-public egress.

For failures or untested paths, report the precise layer, missing evidence and owner-approved next action. Do not automatically remediate shared resources.

Step 12: VNet access configuration (optional)

Ask the user how they will access the private resources of the VNet. The options are:

  1. Existing VPN Gateway (P2S) in another VNet → Step 12A
  2. VM / Bastion / ExpressRoute already available in the same VNet or with peering → Step 12C
  3. No access configured → needs to create one → Step 12B
  4. AI Citadel hub integration (peering + DNS link were created in Step 8d / 9 / 10) → Step 12D post-deploy verification
  5. I will configure it later → skip to Step 13

Step 12 options are not mutually exclusive — a Citadel-spoke deployment commonly also needs operator access (Step 12A/B/C) for az-side validation.

Step 12A — Access via existing VPN Gateway (with peering)

If the user has a VPN Gateway with Point-to-Site configured in another VNet, bidirectional peering with gateway transit is required so VPN clients can reach the new VNet.

12A.1 — Identify the VPN Gateway

# Locate the gateway and its VNet
az network vnet-gateway list --resource-group {gatewayRg} \
  --query "[].{name:name, vnet:ipConfigurations[0].subnet.id}" -o json

# Get P2S address pool
az network vnet-gateway show --resource-group {gatewayRg} --name {gatewayName} \
  --query "{p2sPool:vpnClientConfiguration.vpnClientAddressPool.addressPrefixes}" -o json

--query "{p2sPool:vpnClientConfiguration.vpnClientAddressPool.addressPrefixes}" -o json


#### 12A.2 — Create bidirectional VNet Peering with gateway transit

TWO peerings are required: one from each side. The peering from the gateway's VNet
must enable `allowGatewayTransit`, and the peering from the new VNet must enable
`useRemoteGateways`. This allows P2S clients to reach the new VNet through the
existing gateway.

```powershell
$gatewayVnetId = "/subscriptions/{subId}/resourceGroups/{gatewayRg}/providers/Microsoft.Network/virtualNetworks/{gatewayVnet}"
$newVnetId = "/subscriptions/{subId}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnetName}"

# Peering 1: Gateway VNet → New VNet (allows gateway transit)
az network vnet peering create \
  --resource-group {gatewayRg} \
  --vnet-name {gatewayVnet} \
  --name "peering-to-{vnetName}" \
  --remote-vnet $newVnetId \
  --allow-vnet-access \
  --allow-forwarded-traffic \
  --allow-gateway-transit

# Peering 2: New VNet → Gateway VNet (uses the remote gateway)
az network vnet peering create \
  --resource-group {rg} \
  --vnet-name {vnetName} \
  --name "peering-to-{gatewayVnet}" \
  --remote-vnet $gatewayVnetId \
  --allow-vnet-access \
  --allow-forwarded-traffic \
  --use-remote-gateways

Verify both peerings are in Connected state:

az network vnet peering show --resource-group {gatewayRg} --vnet-name {gatewayVnet} \
  --name "peering-to-{vnetName}" --query "{state:peeringState}" -o tsv
az network vnet peering show --resource-group {rg} --vnet-name {vnetName} \
  --name "peering-to-{gatewayVnet}" --query "{state:peeringState}" -o tsv

⚠️ REQUIREMENT: The address spaces of both VNets MUST NOT overlap. If the gateway's VNet uses 10.0.0.0/16, the new VNet should use 10.1.0.0/16 or another non-overlapping range (this is already validated in Step 6).

12A.3 — Get private IPs of the endpoints

$pes = az network private-endpoint list --resource-group {rg} --query "[].name" -o tsv
foreach ($pe in $pes) {
    $nicId = az network private-endpoint show --name $pe --resource-group {rg} \
      --query "networkInterfaces[0].id" -o tsv
    az network nic show --ids $nicId \
      --query "ipConfigurations[].{ip:privateIPAddress, fqdn:privateLinkConnectionProperties.fqdns[0]}" -o table
}

12A.4 — Generate hosts file entries

First verify the VPN client's DNS configuration with the network owner. Production access should use the approved corporate DNS/Private Resolver path from the network runbook. P2S does not inherently require a hosts file.

Only for an explicitly approved temporary diagnostic, generate entries for C:\Windows\System32\drivers\etc\hosts (or /etc/hosts on Linux/Mac):

# Foundry VNet - {rg} ({vnetName})
{ip}  {fqdn}
...

Present the lines to the user and remind them to:

  1. Edit the hosts file with administrator permissions
  2. Reconnect the VPN client (disconnect and reconnect) so it loads the routes for the new IP range through the peering with gateway transit
  3. Verify connectivity: Test-NetConnection -ComputerName {fqdn} -Port 443
  4. Remove only the temporary entries added for this diagnostic and repeat DNS resolution through the approved resolver before declaring DNS fixed.

Evidence limit: hosts overrides bypass the resolver and can become stale. A successful TCP probe with an override does not validate corporate DNS, TLS, authentication or agent egress.

Step 12B — No access configured (create a new VPN Gateway)

If the user has no means of accessing the VNet, they need to create one. The most common option for development/testing is a VPN Gateway with Point-to-Site.

Inform the user of what they need and offer help to create it:

  1. VPN Gateway in the deployment's VNet (or in another VNet with peering):

    • Requires a GatewaySubnet (minimum /27) in the VNet.
    • Recommended SKU: VpnGw1 (sufficient for development, ~30 min to deploy).
    • Configure P2S with self-signed certificates or Azure AD auth.
    # Create GatewaySubnet in the deployment's VNet
    az network vnet subnet create \
      --resource-group {rg} \
      --vnet-name {vnetName} \
      --name GatewaySubnet \
      --address-prefixes {gatewaySubnetPrefix}  # e.g. 10.1.2.0/27
    
    # Create public IP for the gateway
    az network public-ip create \
      --resource-group {rg} \
      --name "{vnetName}-gateway-pip" \
      --allocation-method Static \
      --sku Standard
    
    # Create VPN Gateway (takes ~30 minutes)
    az network vnet-gateway create \
      --resource-group {rg} \
      --name "{vnetName}-vpn-gateway" \
      --vnet {vnetName} \
      --public-ip-addresses "{vnetName}-gateway-pip" \
      --gateway-type Vpn \
      --vpn-type RouteBased \
      --sku VpnGw1 \
      --vpn-gateway-generation Generation1 \
      --no-wait
  2. Faster alternative — Azure Bastion + VM:
    • Create a VM in the same VNet (in pe-subnet or a new subnet).
    • Configure Azure Bastion for web access to the VM desktop.
    • From the VM, private resources are resolved directly via Private DNS Zones.
    • No hosts file needed.

Ask the user which option they prefer and inform that both take ~30 min to deploy.

Step 12C — Already has access (VM, Bastion, ExpressRoute)

If the user already has access to the VNet (or to a peered VNet) via VM, Bastion, ExpressRoute or other means:

  1. If they access from inside the VNet (VM/Bastion), confirm whether they use Azure-provided or custom DNS. Linked private zones work through Azure DNS; custom DNS still needs a working forwarding path. Do not assume success solely from the VM's location.
  2. If they access from a different VNet with peering: verify that:
    • Bidirectional peering exists with Connected state
    • The caller uses linked Azure DNS zones or the approved centralized resolver
    • If resolution fails, inspect Step 11.8 before requesting a DNS change
  3. If they access via ExpressRoute: verify that the ExpressRoute circuit is connected to the deployment's VNet (directly or via peering), routes cover the intended destinations, and corporate DNS forwards the recommended service namespaces to a reachable Azure resolver with the required zone links.

Step 12D — AI Citadel hub spoke (post-deploy verification)

If Step 8d enabled Citadel hub integration, the deployment has already created the spoke-side peering and (optionally) linked the APIM private DNS zone to the spoke VNet. Two things still need to happen before citadel-spoke-onboarding can run successfully against the project:

12D.1 — Hub team creates the reverse peering

Retrieve the one-line az command from the deployment output and hand it to the Citadel hub team (separate RBAC; they own the hub VNet):

$deploymentName = "foundry-vnet-{deployTimestamp}"
az deployment group show --resource-group {rg} --name $deploymentName `
  --query "properties.outputs.hubReversePeeringCommand.value" -o tsv

The output looks like:

az network vnet peering create --resource-group {hubRg} --vnet-name {hubVnet} \
  --name peering-from-{spokeVnet} \
  --remote-vnet /subscriptions/{spokeSub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{spokeVnet} \
  --allow-vnet-access --allow-forwarded-traffic --subscription {hubSub}

Paste it into a Teams/Slack handoff to the hub team.

12D.2 — Pre-flight checklist

Once the hub team confirms the reverse peering is in place, run operator-side DNS/TCP prechecks from the approved spoke path (an existing peered VM, Bastion session, or VPN-connected client). These do not yet prove actual agent egress:

# 1. Both peerings must be in "Connected" state
az network vnet peering show --resource-group {rg} --vnet-name {vnetName} `
  --name {hubPeeringName} --query peeringState -o tsv      # → "Connected"

# 2. Compare APIM DNS with the intended private endpoint/gateway address.
#    Check the direct zone-link or centralized resolver path.
Resolve-DnsName "{apim}.azure-api.net"

# 3. End-to-end TCP reachability on 443
Test-NetConnection -ComputerName "{apim}.azure-api.net" -Port 443

NSG/firewall/routing review — inspect applicable outbound rules for the actual APIM destination and required identity dependencies. Preserve existing NSGs; attaching one to a delegated subnet is not inherently destructive. Do not add a broad AzureCloud allow as a troubleshooting shortcut. PE network policies determine NSG/UDR applicability; a 0.0.0.0/0 route alone does not prove PE traffic traverses the firewall. Check TLS inspection if TCP succeeds but TLS fails; never disable certificate verification. See Private Endpoint network policies.

After onboarding, verify an actual agent call through APIM and correlate the gateway/backend evidence per Step 11.11. Keep agent egress NOT_TESTED until that succeeds; operator-side DNS/TCP checks are only prerequisites.

12D.3 — Hand off to citadel-spoke-onboarding

Once 12D.1 + 12D.2 pass, the project is ready to be onboarded as a Citadel spoke. Inform the operator:

  1. The Foundry account + project ARM IDs needed by citadel-spoke-onboarding are visible in the deployment outputs (spokeVnetId, agentSubnetName).
  2. Use Option B (Foundry Connection) in citadel-spoke-onboarding — Option A (Key Vault secret pull) violates the keyless-by-mandate posture that VNet-isolated spokes require.
  3. The hub team-owned APIM gateway URL is what the Foundry connection routes to; the agent's UAMI is the only credential threaded through the call.

Step 13: Final notes

After successful verification, remind the user:

  1. To use the Agents they need VNet access (VPN, ExpressRoute, Bastion, or a VM in the VNet).
  2. They can verify the private IPs of the endpoints with get_ips_services.ps1.
  3. The selected template creates the required hosts: account + project for Standard, project for Basic. Verify them by GET; do not run createCapHost.sh as a speculative repair.
  4. If they have a VPN Gateway with peering configured, they must reconnect the VPN client after creating the peering to load the new routes.

4. Important rules

  1. Always use ask_user for each question. DO NOT put multiple questions in a single one.
  2. Offer default values whenever possible to minimize user input.
  3. Validate Resource ID formats before continuing (they must start with /subscriptions/).
  4. Do not generate the .bicepparam until you have ALL parameters.
  5. Save the .bicepparam file with a descriptive name (e.g. deploy-{rg}.bicepparam) in the same directory as main.bicep, never overwrite main.bicepparam.
  6. The Bicep files live under the templates/ subfolder of this skill, one directory per template: templates/standard-agent/ (BYO Search/Storage/Cosmos — template 15) and templates/basic-vnet/ (platform-managed storage — template 11). Step 0 selects which one; set TEMPLATE_DIR to that directory and use it (main.bicep, main.bicepparam, modules-network-secured/) as the deployment working directory — copy them out to a workspace folder first if you want to keep the originals pristine.
  7. If the user passed a one-line scenario hint when invoking the skill (see the Goal section), use it to pre-fill values whenever possible.
  8. Always generate and store a fixed deploymentTimestamp before the first deployment. Pass it as --parameters deploymentTimestamp={timestamp} on every attempt (including retries). This guarantees that uniqueSuffix is identical and resources are not duplicated.
  9. On retries, inspect deployment operations, the read-only network inventory and the selected template's capability-host GET state first. Follow Step 10b: no PUT probes, no automatic purge/deletion, and no retry while provisioning is still in progress. Keep the same timestamp unless a new plan is approved.
  10. Citadel hub integration is opt-in via Step 8d. When the spoke will be onboarded as a Citadel hub spoke, complete Step 12D (hub team creates the reverse peering, all three pre-flight checks pass) before running citadel-spoke-onboarding. Otherwise the injected APIM Foundry connection's first call will time out — either no route to the hub, or no DNS resolution for {apim}.azure-api.net.

5. References

  • Manual inventory acceptance (2026-09-13): the canonical helper was run against existing CI account/subnet resources, including an account and VNet in separate resource groups. It reported public access, no approved PEs and the existing SAL without changing resources; private-posture blockers returned exit 1. A nonexistent account returned exit 2, not an empty success. The approved-PE/no-blocker branch is unit-tested only. This evidence covers the added read-only diagnostics, not a new deployment, recovery, private DNS/TCP or inference E2E; runtime_connectivity remains NOT_TESTED. Vendored templates were not changed.
  • Networking workflow integration — the official private-network workflow informs the ownership/caller-path intake and separation of control-plane checks from runtime testing. It does not replace our vendored templates or authorize automatic VNet replacement. For the read-only subnet inspection surface, see Microsoft Learn — diagnose blocking subnet resources.
  • Foundry Samples — 15-private-network-standard-agent-setup — Bicep template set under templates/ derives from this Foundry samples reference.
  • Agent networking deep-dive — hosted vs prompt agent traffic paths, the ~1-IP-per-10-pods allocation model, subnet sizing against the 50-session platform cap (per subscription/region), hosted-vs-prompt revision IP behavior, and subnet-exhaustion signals: references/agent-networking.md.
  • Agent tools behind the VNet — the tool-by-tool reachability matrix (through-subnet / through-PE / backbone / public / unsupported), isolation feature limits, project-date-dependent private ACR support, hosted injection constraints, the firewall FQDN allowlist, and troubleshooting: references/agent-tools-network-isolation.md.
  • Private-network diagnostic evidence — foundry-network-runbook separates positive/negative ingress checks from actual agent egress. Private Endpoint DNS integration is the source for hybrid forwarding and DNS zone-group behavior.
  • Original interview/automation logic — Angel Sevillano (Microsoft), asevillano/foundry-vnet-deploy.
  • Related skills — azure-tenant-isolation (set up first), foundry-hosted-agents (deploy agents into the host this skill creates), threadlight-deploy (azd-based public-network alternative), foundry-cross-resource (APIM cross-resource model wiring on top), citadel-spoke-onboarding — see Step 8d + Step 12D for the network plumbing this skill creates so the deployed Foundry can be onboarded as a Citadel hub spoke, foundry-observability (App Insights wiring if Step 8c was opted in).
  • Template fork notice — the templates/ set vendors two Foundry samples references: templates/standard-agent/ from 15-private-network-standard-agent-setup and templates/basic-vnet/ from 11-private-network-basic-vnet. awesome-gbb adds the same four optional integrations on top of each template: (1) modules-network-secured/spoke-hub-peering.bicep and (2) modules-network-secured/apim-dns-zone-link.bicep behind the hubVnetResourceId / apimDnsZoneResourceId params; (3) a hubReversePeeringCommand output; (4) hosted-agent developer RBAC (agent-developer-role-assignments.bicep + agent-developer-subnet-assignment.bicep) and project-MI telemetry roles (app-insights-role-assignments.bicep). Note App Insights + Log Analytics + private trace ingestion (Monitor Private Link Scope) are upstream-native in template 11 — only the project-MI role delta is awesome-gbb. Future upstream syncs must diff each vendored tree against its origin and re-apply these additions. Pinned SHA + revalidation contract: references/upstream-pin.md.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.