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
- Distinctive: line 26
Before hosted artifact preparation, select the versioned hosted profile and complete its capability/permission feasibility gate.
- Very specific: line 115
Follow these steps IN ORDER.
- Very specific: line 158
Read back the project host's capabilityHostKind: Agents and provisioningState: Succeeded before registering a hosted version, including …
- Very specific: line 181
Run az account show in that isolated context.
- Distinctive: line 187
Ask who owns the VNet, private DNS zones, NSGs/routes and reverse peering, including their resource groups/subscriptions.
- Distinctive: line 202
Subscription: Do they want to use the current subscription or change it?
- Distinctive: line 204
If it needs to be created, ask for the name and location.
- Distinctive: line 205
If it already exists, ask for the name.
- Distinctive: line 209
Ask for the deployment region.
- Distinctive: line 286
Ask whether the user has existing resources they want to reuse:
- Very specific: line 288
Do you have an existing AI Search?: If yes → ask for aiSearchResourceId (full ARM format).
- Very specific: line 289
Do you have an existing Storage Account?: If yes → ask for azureStorageAccountResourceId.
- Very specific: line 290
Do you have an existing CosmosDB?: If yes → ask for azureCosmosDBAccountResourceId.
- Distinctive: line 292
If none are provided, all of them will be created automatically.
- Distinctive: line 303
If NO → all zones will be created automatically (6 zones).
- Very specific: line 339
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 …
- Distinctive: line 340
If the user does not want to use it now, leave the list empty and it can be added manually later.
- Very specific: line 360
If the spoke project will be onboarded into an AI Citadel Governance Hub as a spoke (see citadel-spoke-onboarding), this template can …
- Very specific: line 365
If no, leave the four hub* / apimDnsZone* parameters empty in Step 9 and continue.
- Distinctive: line 366
If yes, ask the next questions.
- Very specific: line 390
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 …
- Very specific: line 396
When TEMPLATE_DIR=templates/basic-vnet, use the basic-vnet fork block after the IMPORTANT notes instead.
- Very specific: line 475
If a value is empty (not provided by the user), use the empty string ''.
- Distinctive: line 477
If using a new VNet without prefixes, leave the subnet prefixes empty (they will be calculated automatically).
- Very specific: line 479
Use a descriptive name for the .bicepparam file (e.g.
- Distinctive: line 557
After tenant isolation, use the approved resource names (the VNet can be in a different resource group within the selected subscription):
- Distinctive: line 596
Ask the user whether to proceed with the deployment.
- Distinctive: line 598
If they confirm, generate a fixed timestamp for the deployment and store it:
- Distinctive: line 604
Run the deployment passing the timestamp as an explicit parameter:
- Distinctive: line 635
If the deployment fails (frequently due to a timeout of the Account Capability Host), follow these steps to retry without duplicating …
- Very specific: line 641
Inspect before any retry: Read the deployment operations from Step 10, run network_inventory.py from Step 9a for resources that exist, and …
- Distinctive: line 653
Do not automatically purge the account, change VNet names, remove a SAL or delete resources by suffix: they may belong to an existing …
- Very specific: line 667
If the same deterministic error recurs after two approved attempts, stop unchanged retries.
- Distinctive: line 670
Do not bypass the template with manual writes to shared hosts or role grants.
- Distinctive: line 674
After the deployment (successful or after completing the retry steps), run ALL these checks and present the results to the user as a status …
- Very specific: line 678
control-plane only: Keep runtime DNS, TCP 443 and authenticated agent/tool checks from the actual caller network separate.
- Distinctive: line 682
Use the runbook's paired ingress and real agent-egress checks.
- Very specific: line 722
Verify: provisioningState = Succeeded, model and capacity match what was requested.
- Very specific: line 732
Verify: provisioningState = Succeeded, identity.type = SystemAssigned, principalId is not null.
- Distinctive: line 764
Follow the shared preflight.
- Distinctive: line 775
Verify: 3 connections exist (CosmosDb, CognitiveSearch, AzureStorageAccount) with correct targets.
- Very specific: line 796
Verify: 4 Private Endpoints, all with status Approved.
- Distinctive: line 804
Use each zone's owning subscription/resource group and compare the exact VNet resource ID, not a resource-group substring.
- Distinctive: line 807
Read the links for one approved zone at a time:
- Very specific: line 822
If a link/group/record is missing, report the exact owner and discrepancy.
- Very specific: line 842
Verify: publicNetworkAccess = Disabled on the 3 resources.
- Distinctive: line 852
Verify the following exist:
- Very specific: line 865
Verify: Built-in Data Contributor scoped to dbs/enterprise_memory.
- Distinctive: line 890
Only claim the observed paths succeeded when their runtime evidence is present.
- Distinctive: line 898
Ask the user how they will access the private resources of the VNet.
- Distinctive: line 910
If the user has a VPN Gateway with Point-to-Site configured in another VNet, bidirectional peering with gateway transit is required so VPN …
- Distinctive: line 1124
Test-NetConnection -ComputerName "{apim}.azure-api.net" -Port 443
≈ “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.
Files
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… | Template | TEMPLATE_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 ACR | basic-vnet (template 11) | templates/basic-vnet |
Ask the user:
- 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.
- Yes / not sure / need vector search + custom thread storage →
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):
| Aspect | standard-agent (15) | basic-vnet (11) |
| BYO resources | AI Search + Storage + Cosmos (create or reuse) | none (platform-managed) |
| Project connections | CosmosDb, CognitiveSearch, AzureStorageAccount (+ appinsights) | appinsights only |
| Private endpoints | AI Services, Search, Storage(blob), Cosmos(SQL) (4) | AI Services + Monitor PLS (2) + ACR (optional → 3) |
| Private DNS zones | 6 (services.ai, openai, cognitiveservices, search, blob, documents) | 3 app (services.ai, openai, cognitiveservices) + azurecr.io (if ACR) + 4 monitor zones |
| Optional private ACR | no | yes (enableContainerRegistry, default true; developerIpCidr) |
| Account capability host | yes (accountCapHost) | project caphost only (projectCapHost = caphostproj) |
| Role assignments | 6 (project-MI over BYO) + optional developer/telemetry | telemetry (LAW Reader + Azure AI User) + optional developer + ACR AcrPull |
| Deploy time | 45-90 min | 30-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 + optionalazurecr.io;existingMonitorDnsZones= 4 monitor zones), and you additionally ask about the optional private ACR (enableContainerRegistry,developerIpCidr).
Step 1: Verify prerequisites
Before starting, verify:
- Apply
azure-tenant-isolationbefore 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. - Run
az account showin that isolated context; confirm the intended target with the user. If authentication is missing, use the tenant-isolation workflow. - 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.
- 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.
- 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:
- Subscription: Do they want to use the current subscription or change it? If they want to change, ask for the subscription ID.
- 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:
- aiServices: Prefix for the AI Services resource (default:
foundry). A unique suffix will be appended automatically. - firstProjectName: Project name (default:
project). - displayName: Visible project name (default: same as the project name).
- projectDescription: Project description (default:
A project for the AI Foundry account with network secured deployed Agent). - accountCapHost: Name of the account-level capability host (default:
caphostacct). This resource setscustomerSubnetfor the agent runtime network injection.
Step 5: Model configuration
Ask:
- 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. - modelFormat: Model provider (default:
OpenAI). - modelVersion: Model version. Depends on the chosen model.
- modelSkuName: Deployment SKU (default:
GlobalStandard). Options: GlobalStandard, Standard, ProvisionedManaged. - 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/environmentsand 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 CIDR Usable IPs Concurrent sessions Use when /24 251 50 (platform cap) + upgrade buffer Production default (Microsoft-recommended) /25 123 50 (platform cap) Buffer between /26 and /24 /26 59 ~50 — minimum to reach the cap Smallest that supports the full 50 /27 27 ~17 Dev/test only — minimum, risky Rules: RFC-1918 only (10/8, 172.16/12, 192.168/16) — the CGNAT
100.64.0.0/10range is not supported (routing failures). Avoid172.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:
- vnetName: VNet name (default:
agent-vnet). - vnetAddressPrefix: Address space (default:
192.168.0.0/16). Also supports10.x.x.x/16or172.16.x.x/16. - agentSubnetPrefix: CIDR of the agent subnet (default: automatically calculated as the first /24 of the address space).
- peSubnetPrefix: CIDR of the private endpoint subnet (default: automatically calculated as the second /24).
- agentSubnetName: Name of the agent subnet (default:
agent-subnet). - peSubnetName: Name of the PE subnet (default:
pe-subnet).
Option B: Existing VNet
Ask:
- existingVnetResourceId: Full resource ID of the VNet. Format:
/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{name}. - agentSubnetName: Name of the agent subnet (will be created if it does not exist). IMPORTANT: This subnet needs delegation to
Microsoft.App/environments. - peSubnetName: Name of the private endpoint subnet (will be created if it does not exist).
- agentSubnetPrefix: CIDR of the agent subnet (mandatory if the subnet does not exist). WARNING: Must not overlap with existing subnets.
- 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:
- Do you have an existing AI Search? If yes → ask for
aiSearchResourceId(full ARM format). - Do you have an existing Storage Account? If yes → ask for
azureStorageAccountResourceId. - 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:
- dnsZonesSubscriptionId: Subscription where the DNS zones are located (leave empty if it is the same as the deployment).
- For each zone, ask whether it exists and which resource group it is in:
privatelink.services.ai.azure.comprivatelink.openai.azure.comprivatelink.cognitiveservices.azure.comprivatelink.search.windows.netprivatelink.blob.core.windows.netprivatelink.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
dnsZonesSubscriptionIdparameter combined with the per-zone resource-group map inexistingDnsZonesis 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 needsPrivate DNS Zone Contributoron each zone in the hub subscription to create the VNet links; without this you will hitInvalidPrivateDnsZoneIdsat deploy time.
- For DNS troubleshooting, including
InvalidPrivateDnsZoneIds, usefoundry-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 toward168.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 Operatoron the Foundry accountNetwork Contributoron 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:
- 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 tsvoraz 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.
- Ask for a list of AAD objectIds (you can get them with
- 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
appinsightsconnection in the project + assigns to the project MI:Log Analytics Readeron the workspaceAzure AI Useron 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:
- 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:
- 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 samedeploymentTimestamp. - If yes, ask the next questions.
- If no, leave the four
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 createin thehubReversePeeringCommandoutput 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.
- 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
apimDnsZoneResourceId(optional) — full ARM ID of the existingprivatelink.azure-api.netprivate 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.netto its private IP.
hubPeeringName(optional, defaultpeering-to-hub) — friendly name for the spoke-side peering.apimDnsZoneLinkName(optional, defaultfoundry-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 443If 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
dnsZoneNamesparameter 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
deploymentTimestampin the.bicepparamfile. 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
.bicepparamfile (e.g.deploy-{resourceGroup}.bicepparam) so as not to overwrite the project's originalmain.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-zonednsZoneNames). Before writingexistingDnsZones/existingMonitorDnsZones/enableContainerRegistryvalues, the key names below are the vendoredtemplates/basic-vnet/main.bicepdefaults — 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 reference | Purpose |
references/python/network_inventory.py | Tenant-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
- 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)".
- Ask the user whether to proceed with the deployment.
- If they confirm, generate a fixed timestamp for the deployment and store it:
This timestamp will be reused if the deployment has to be retried.$deployTimestamp = Get-Date -Format 'yyyyMMddHHmmss' 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.- 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 - 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:
- Reuse the SAME timestamp (
$deployTimestamp) as the original attempt. This is the most important thing to avoid duplication. - Inspect before any retry. Read the deployment operations from Step 10,
run
network_inventory.pyfrom 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. - 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-lifecycleto 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. - 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}" - 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 jsonVerify:
provisioningState=SucceededpublicNetworkAccess=DisablednetworkInjections[0].scenario=agentand 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 tableVerify: 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 jsonVerify: 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=SucceededcapabilityHostKind=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 tableVerify: 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 jsonVerify:
- Address space is correct
agent-subnetdelegated toMicrosoft.App/environmentspe-subnetwithout delegation
11.7 — Private Endpoints
az network private-endpoint list --resource-group {rg} \
--query "[].{name:name, status:privateLinkServiceConnections[0].privateLinkServiceConnectionState.status}" -o tableVerify: 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 jsonA 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 jsonVerify: 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 tableVerify the following exist:
Storage Blob Data Contributor(scope: storage account)Storage Blob Data Owner(scope: storage account, with ABAC condition)Cosmos DB Operator(scope: cosmos account)Search Index Data Contributor(scope: search service)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 tableVerify: 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:
| Component | Status | Detail |
| 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_TESTED | actual caller, expected PE DNS, TLS and authenticated response |
| Public-route rejection | ✅/❌/NOT_TESTED | approved external caller, valid auth, network-specific rejection |
| Agent/tool egress | ✅/❌/NOT_TESTED | actual 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:
- Existing VPN Gateway (P2S) in another VNet → Step 12A
- VM / Bastion / ExpressRoute already available in the same VNet or with peering → Step 12C
- No access configured → needs to create one → Step 12B
- AI Citadel hub integration (peering + DNS link were created in Step 8d / 9 / 10) → Step 12D post-deploy verification
- 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-gatewaysVerify 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:
- Edit the hosts file with administrator permissions
- Reconnect the VPN client (disconnect and reconnect) so it loads the routes for the new IP range through the peering with gateway transit
- Verify connectivity:
Test-NetConnection -ComputerName {fqdn} -Port 443 - 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:
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- Requires a
- Faster alternative — Azure Bastion + VM:
- Create a VM in the same VNet (in
pe-subnetor 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.
- Create a VM in the same VNet (in
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:
- 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.
- If they access from a different VNet with peering: verify that:
- Bidirectional peering exists with
Connectedstate - The caller uses linked Azure DNS zones or the approved centralized resolver
- If resolution fails, inspect Step 11.8 before requesting a DNS change
- Bidirectional peering exists with
- 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 tsvThe 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 443NSG/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
AzureCloudallow as a troubleshooting shortcut. PE network policies determine NSG/UDR applicability; a0.0.0.0/0route 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:
- The Foundry account + project ARM IDs needed by
citadel-spoke-onboardingare visible in the deployment outputs (spokeVnetId,agentSubnetName). - 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. - 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:
- To use the Agents they need VNet access (VPN, ExpressRoute, Bastion, or a VM in the VNet).
- They can verify the private IPs of the endpoints with
get_ips_services.ps1. - The selected template creates the required hosts: account + project for Standard,
project for Basic. Verify them by GET; do not run
createCapHost.shas a speculative repair. - 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
- Always use
ask_userfor each question. DO NOT put multiple questions in a single one. - Offer default values whenever possible to minimize user input.
- Validate Resource ID formats before continuing (they must start with
/subscriptions/). - Do not generate the .bicepparam until you have ALL parameters.
- Save the .bicepparam file with a descriptive name (e.g.
deploy-{rg}.bicepparam) in the same directory asmain.bicep, never overwritemain.bicepparam. - The Bicep files live under the
templates/subfolder of this skill, one directory per template:templates/standard-agent/(BYO Search/Storage/Cosmos — template 15) andtemplates/basic-vnet/(platform-managed storage — template 11). Step 0 selects which one; setTEMPLATE_DIRto 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. - 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.
- Always generate and store a fixed
deploymentTimestampbefore the first deployment. Pass it as--parameters deploymentTimestamp={timestamp}on every attempt (including retries). This guarantees thatuniqueSuffixis identical and resources are not duplicated. - 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.
- 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_connectivityremainsNOT_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 undertemplates/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-runbookseparates 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/from15-private-network-standard-agent-setupandtemplates/basic-vnet/from11-private-network-basic-vnet. awesome-gbb adds the same four optional integrations on top of each template: (1)modules-network-secured/spoke-hub-peering.bicepand (2)modules-network-secured/apim-dns-zone-link.bicepbehind thehubVnetResourceId/apimDnsZoneResourceIdparams; (3) ahubReversePeeringCommandoutput; (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.
In this repository46 other files it gives its agents.
- AGENTS.mdAGENTS.md
- docs/llms.txtllms.txt
- agent-framework-harnessSkill
- auto-demo-producerSkill
- azd-patternsSkill
- azure-backup-readinessSkill
- azure-monitor-alert-baselineSkill
- azure-resource-diagnosticsSkill
- azure-sre-agentSkill
- azure-tenant-isolationSkill
- citadel-hub-deploySkill
- citadel-spoke-onboardingSkill
- copilot-doctorSkill
- foundry-agentopsSkill
- foundry-agtSkill
- foundry-caphost-lifecycleSkill
- foundry-cost-monitoringSkill
- foundry-cross-resourceSkill
- foundry-doc-vision-speechSkill
- foundry-evalsSkill
- foundry-hosted-agentsSkill
- foundry-iqSkill
- foundry-mcp-aca-jobsSkill
- foundry-mcp-acaSkill
- foundry-mcp-authSkill
- foundry-network-runbookSkill
- foundry-observabilitySkill
- foundry-prompt-agentsSkill
- foundry-rbac-auditSkill
- foundry-routinesSkill
- foundry-skill-catalogSkill
- foundry-teams-botSkill
- foundry-toolboxSkill
- foundry-voice-agentsSkill
- foundry-voice-liveSkill
- gbb-humanizerSkill
- gbb-pptxSkill
- ghcp-cli-configSkill
- ghcp-hosted-agentsSkill
- html-deck-to-pptxSkill
- html-presentationSkill
- ip-catalogSkill
- paygo-ptu-cost-analyzerSkill
- progress-guardSkill
- token-economicsSkill
- web-experience-designSkill
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
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.

