Take a designed agent project (from threadlight-design or hand-crafted) and generate all deployment artifacts for Microsoft Foundry Hosted Agents. Reads specs/SPEC.md, AGENTS.md, and skills to produce container.py, Dockerfile, pyproject.toml, an azd project, and deploy-notes.md for one-command `azd up`. Also runs in Kratos-export mode: handed a Kratos-exported project (src/hosted-agent/ + use-cases/<x>/) it enriches/validates only and backfills the missing evals/ directory. USE FOR: deploy to Foundry, make this deployable, generate deployment files, Foundry hosted agent, containerize agent, package agent, deploy agent, azd deploy, azd up, Kratos export, foundry-agent.zip, backfill evals. DO NOT USE FOR: designing the process (use threadlight-design), running evals (use foundry-evals), Teams bot (use foundry-teams-bot), MCP server deployment (use foundry-mcp-aca), GHCP SDK variant (use ghcp-hosted-agents), azd tenant isolation (use azure-tenant-isolation).
Installation
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Ce SKILL.md est tres volumineux, SkillsMP affiche donc ici seulement la premiere section.Voir sur GitHub
name
threadlight-deploy
description
Take a designed agent project (from threadlight-design or hand-crafted) and generate all deployment artifacts for Microsoft Foundry Hosted Agents. Reads specs/SPEC.md, AGENTS.md, and skills to produce container.py, Dockerfile, pyproject.toml, an azd project, and deploy-notes.md for one-command `azd up`. Also runs in Kratos-export mode: handed a Kratos-exported project (src/hosted-agent/ + use-cases/<x>/) it enriches/validates only and backfills the missing evals/ directory. USE FOR: deploy to Foundry, make this deployable, generate deployment files, Foundry hosted agent, containerize agent, package agent, deploy agent, azd deploy, azd up, Kratos export, foundry-agent.zip, backfill evals. DO NOT USE FOR: designing the process (use threadlight-design), running evals (use foundry-evals), Teams bot (use foundry-teams-bot), MCP server deployment (use foundry-mcp-aca), GHCP SDK variant (use ghcp-hosted-agents), azd tenant isolation (use azure-tenant-isolation).
metadata
{"version":"1.6.3"}
Foundry Hosted Agent Deploy
⚠️ Azure Tenant Isolation (mandatory). Before running any Phase that
touches Azure (azd up, az deployment, az acr build), verify tenant
isolation per the azure-tenant-isolation
skill: set AZURE_CONFIG_DIR + AZD_CONFIG_DIR, assert tenant +
subscription with az account show, then proceed. Check token validity
first — only prompt az login if az account show fails.
Take a project folder (containing AGENTS.md, src/agent/skills/, config/, etc.) and enrich
it with all files needed to deploy as a Microsoft Foundry Hosted Agent.
Default runtime: GHCP SDK (CopilotClient + InvocationAgentServerHost, Invocations
protocol). Falls back to MAF (Agent + FoundryChatClient + ResponsesHostServer,
Responses protocol) when Toolbox tools or custom @tool functions are needed.
Uses the azd ai agent extension for declarative deployment — azure.yaml defines
agent configuration, model deployments, and container resources; azd up handles everything.
When to Use
User has a designed agent project and wants to deploy it to Foundry
User asks to "make this deployable" or "package for Foundry"
User wants to containerize their agent for hosted deployment
User asks for Dockerfile, container runtime, or deployment files
User asks about MCP tools in Foundry hosted agents
User hands you a Kratos-exported project and wants to harden it for production
Input Modes
This skill accepts two starting points. Detect which one you're in before
Phase 1 and branch accordingly — they are additive, with no change to the
existing design-driven path.
Mode
Detection signal
What this skill does
threadlight-design mode (default)
AGENTS.md + src/agent/skills/ present; no src/hosted-agent/
Generate all deploy artifacts from scratch (Phases 0–7, unchanged).
Kratos-export mode
src/hosted-agent/anduse-cases/<x>/ both present
Enrich/validate only. The runtime (Dockerfile, main.py, pyproject.toml, agent.yaml, azure.yaml) already exists — do not regenerate it. Backfill evals/, validate, and hand off to the production-hardening skills.
[!IMPORTANT]
Kratos-export mode skips Phase 2 (Generate Deployment Artifacts). Kratos's
exporter already ships a deployable full-clone project; re-running the
generators would clobber src/hosted-agent/main.py, Dockerfile, and
azure.yaml. In this mode the skill enriches and validates in place. See the
full runbook in references/kratos-export-mode.md
and the bridge overview in docs/KRATOS-BRIDGE.md.
Skills root. Kratos puts skills under use-cases/<x>/skills/;
threadlight-design puts them under src/agent/skills/. Resolve the skills root
with this precedence: (1) explicit --skills-root <path> override, (2)
use-cases/<x>/skills/ if present (Kratos-export mode), (3) src/agent/skills/
(design mode). No symlinks, no moving files.
Why Hosted Agents (not Prompt/Declarative Agents)
Foundry offers simpler agent types (PromptAgentDefinition, DeclarativeAgentDefinition)
that run on Foundry's servers with no custom container. However, these cannot support:
SkillsProvider — progressive skill discovery and on-demand loading
For any agent that uses skills, custom middleware, or complex logic, you MUST use
HostedAgentDefinition with a custom container.
Prerequisites
Kratos-export mode (alternative input). When starting from a Kratos export,
the inputs below live under use-cases/<x>/ instead: SYSTEM_PROMPT.md (in
place of AGENTS.md), use-cases/<x>/skills/*/SKILL.md, apm.yml, and
.mcp.json. The hosted-agent runtime is under src/hosted-agent/. See
references/kratos-export-mode.md.
Always — Bicep module library that Phase 6 (Module Composer) reads from
foundry-iq
Default for every process — provisions the Knowledge Agent + AI Search index for SPEC § 7 knowledge sources
foundry-teams-bot
If Teams integration is needed
foundry-mcp-aca
If deploying custom MCP servers as ACA or Azure Functions
threadlight-workspace-ui
If SPEC § 8b specifies an operator workspace
threadlight-hitl-patterns
If SPEC § 8 declares any human action gate (approve/edit-and-approve/reject/escalate/signoff/audit-view/request-info)
threadlight-event-triggers
If SPEC § 10b declares any event-driven, scheduled, or webhook trigger
threadlight-demo-data-factory
If SPEC § 5 marks any system as mock (almost always true for pilots)
foundry-doc-vision-speech
If SPEC § 7b selects any vision / DocIntel / Speech model
foundry-evals
For post-deployment evaluation AND continuous evaluation: Plan A (default) — Foundry's built-in scheduled evaluations (no extra infra). Plan B (fallback) — ACA Job cron eval that reads from App Insights and writes to Workbook (use only when Plan A doesn't yet support hosted-agent eval kinds you need). Phase 6 includes the ACA Job ONLY when SPEC § 9 sets continuous_eval.plan: "B"
citadel-spoke-onboarding
Phase 7 (opt-in) — runs ONLY when SPEC § 11b sets governance_hub.required: yes
threadlight-workflow
Phase 2 alternative — runs ONLY when SPEC § 11e sets workflow_model: "workflow"AND the skill is installed (/skills list). Generates a MAF Workflow container instead of an Agent container, then Phase 5-6 pick it up. If it is not installed, do NOT block or hunt for it — fall back to the Phase 2 agent container path (see the Workflow model gate below).
Use /skills list to check availability. If missing, install from aiappsgbb/awesome-gbb.
Rule: each threadlight process gets ONE repo. ONE repo = ONE process = ONE
azd up. Never multi-process repos.
Why
We learned this the hard way in older multi-process repos. Multi-process
repos:
Inflate Bicep into one giant template with 70% if blocks
Force unrelated processes to share azd env, breaking iteration
Make customer hand-off awkward (they only want one process; they get all 13)
Concentrate blast radius — one botched deploy takes down siblings
Pre-flight checklist (run this FIRST, before Phase 1)
Inspect the input folder. If ANY of these are true, stop and ask the user
to split the repo before proceeding:
More than one specs/SPEC.md exists at any depth
More than one AGENTS.md exists at any depth
The folder name contains a plural / catalog noun (processes/, catalog/, pilots/)
The folder contains nested specs/<process-slug>/SPEC.md siblings
A previous run produced an azure.yaml with multiple services: entries that
point to different agent containers
How to split
Before (rejected): After (each is its own azd up):
<multi-process-repo>/ <your-process-repo>/
├── <process-a>/ ├── specs/
│ ├── specs/ │ └── SPEC.md
│ └── src/ ├── src/
├── <process-b>/ └── azure.yaml
│ ├── specs/
│ └── src/ retail-pim-enrichment/
└── azure.yaml (← shared) ├── specs/
│ └── SPEC.md
├── src/
└── azure.yaml
The threadlight-design skill respects this by default — it generates one
self-contained subtree per process. This skill enforces it.
MCP in Foundry
Hosted agent containers connect to MCP servers using client.get_mcp_tool() from
FoundryChatClient. The container loads mcp-config.json at startup and creates
tool instances for each configured server.
Foundry only accepts remote HTTP MCP endpoints (no stdio/local)
ACA: Streamable HTTP at /mcp endpoint (JSON-RPC over HTTP POST)
Azure Functions: HTTP Streamable transport
Non-streaming tool call timeout: 100 seconds
Private MCP (VNet) requires Standard Agent Setup
Phase 1: Analyze the Design
Phase 1.0 — Detect input mode (do this first). Check for
src/hosted-agent/anduse-cases/<x>/. If both exist, you are in
Kratos-export mode: the runtime is already generated. Read
references/kratos-export-mode.md, then
skip Phase 2 entirely and resume at Phase 3 (Validate) — but first run the
Kratos-mode enrichment steps in that reference (skills-root resolution, evals
backfill, azd env cross-check). If only AGENTS.md + src/agent/skills/
are present (no src/hosted-agent/), you are in design mode — proceed with
Phase 1 below as usual.
Read all available input files in this priority order:
1a. Read specs/manifest.json (if exists)
Machine-readable deployment contract from threadlight-design. Provides:
Process name, traits, business rule count
Mock systems list → flag for deploy-notes warnings
Per-executor skill context (each phase reads its own skills)
MCP
mcp_servers parameter
client.get_mcp_tool()
Per-executor MCP tools (same as MAF agent)
HITL
Tool-mediated
Tool-mediated
Workflow pause points (durable wait-for-external-event; persona gates map to pause/resume)
Custom @tool
❌ Not supported
✅ Supported
✅ Supported (inside executors)
Tool loop timeout
No limit (SSE keeps alive)
120s gateway timeout
Per-executor timeout (workflow manages overall)
Auth
BYOK (DefaultAzureCredential → bearer token)
DefaultAzureCredential → FoundryChatClient
Same as MAF Agent
Best for
Open-ended chat, Q&A, RAG, exploration
Data queries, Toolbox, file generation
Deterministic multi-phase processes with persona gates (expense claim, hiring, KYC, contract review)
Decision rules:
Default to GHCP — preferred runtime, progressive skills, no timeout limits
Use MAF Agent when: agent needs Foundry Toolbox (web_search, code_interpreter) OR custom @tool functions OR file generation (save_report → XLSX/PDF/CSV)
Use MAF Agent when: agent primarily does data queries with fast MCP tools — MAF is 10-20x faster for these (19s vs 220s+). The 20-34 extra load_skill-shaped calls per query are CopilotClient runtime overhead, NOT SkillsProvider overhead — SkillsProvider itself only adds +1 load_skill per skill the agent activates per query, and works on both runtimes (see foundry-hosted-agents § Skill Loading)
Use MAF Workflow when: SPEC § 11e sets workflow_model: "workflow" — deterministic multi-phase processes where the phase order is fixed, persona gates control progression, and the orchestrator (not the LLM) decides what runs next. This is the MAF equivalent of Zava-style durable orchestration.
If the spec doesn't indicate either way → use GHCP
1e. Choose model access pattern
Pattern
When to Use
How
Direct deployment (default)
You deploy the model in your own Foundry project
azure.yamlconfig.deployments — model created by azd up
AI Gateway (APIM)
Use an existing model on another Foundry resource, or a shared/governed model pool
ApiManagement connection in the Foundry project → APIM routes to backend AI Services
Use AI Gateway when:
Customer has existing model deployments they want to reuse
A shared model pool is managed centrally (e.g., Citadel hub)
Governance requires routing through APIM (logging, rate limiting, policies)
You need models from a different Azure region or subscription
See foundry-cross-resource skill for the full AI Gateway setup —
APIM connection creation, connectionName/deploymentName pattern,
Bicep for managed connections, and troubleshooting.
When using AI Gateway:
Remove the model from azure.yamlconfig.deployments (it's already deployed elsewhere)
Set MODEL_DEPLOYMENT_NAME in agent.yaml to connectionName/deploymentName
Ensure the Foundry project has an ApiManagement connection to the APIM gateway
Use authType: "AAD" (recommended) — no Key Vault needed. ApiKey auth requires a Key Vault on the project, which our Bicep scaffold doesn't create.
Works with both GHCP SDK (BYOK) and MAF (FoundryChatClient) — routing is transparent
See ghcp-hosted-agents skill for the full GHCP reference (container.py template,
pyproject.toml, agent.yaml, invocation patterns, troubleshooting).
See foundry-hosted-agents skill for the full MAF reference.
Phase 1.5: Deployment-Posture Gate (interactive when SPEC § 11f is silent)
Trigger (two paths): mirror of Phase 7 (Citadel) — explicit when SPEC
pre-declares, interactive when SPEC is silent. The goal is the same: never
silently equate "deploy" with pilot-posture defaults, especially when the
operator said "production-grade".
Why this is its own phase (not folded into Phase 1)
Phase 1 reads the spec; Phase 1.5 records an effective posture decision
that downstream phases (Phase 6 module composer, Phase 5 azd hooks) consume
mechanically.
The posture record outlives a single run — when an operator re-runs
threadlight-deploy after a tweak, Phase 1.5 reads
specs/deployment-posture.md first and only re-prompts when the SPEC and
the posture file disagree, or when the file is missing.
"Production-grade" means different things in different deployments
(public ingress at a startup, private endpoints + DR at a regulated
bank). Asking once and writing the answer down avoids 20-30 min provision
cycles spent rediscovering the same trade-offs.
Inputs / Outputs
Reads: specs/SPEC.md § 11f (optional) and specs/deployment-posture.md
(when present from a prior run).
Writes: specs/deployment-posture.md — the canonical deploy-time
decision record. Phase 5 / 6 hooks materialize azd env vars from this
file later; Phase 1.5 does NOT write azd env vars itself (the azd
scaffold doesn't exist yet at this point).
Authority order (drift mitigation): SPEC § 11f → specs/deployment-posture.md
→ azd env vars (materialized only, never authoritative).
When either of the following is true, Phase 1.5 takes the matching
posture defaults silently (no operator prompt) and proceeds to Phase 2:
SPEC § 11f sets deployment_target to demo-sandbox,
customer-pilot, or production-bound. Phase 1.5 writes
specs/deployment-posture.md with source: provided on every row.
specs/deployment-posture.md already exists with a valid
deployment_target value (and no conflicting SPEC § 11f — see Re-run
contract below). This is the canonical CI / non-interactive override
pattern: a wrapper (workflow, script, prior run) seeds the posture file
ahead of time, and Phase 1.5 honours it. Do NOT overwrite an existing
posture file with a fresh interactive prompt.
Default posture per target (each row tagged as supported-now today vs
deferred = recorded in posture but NOT auto-implemented in this PR):
Posture row
demo-sandbox
customer-pilot
production-bound
supported-now?
Networking
public
public
private-required
public ✓ / private-required = deferred (needs azd-patterns VNet work)
When the SPEC does not pre-declare § 11f, Phase 1.5 prompts the operator
with audience-neutral wording (no "the customer's tenant" — say "the
target subscription" so internal IT / SI partner operators see themselves
in the question):
Deployment posture. What kind of deployment is the target
subscription expecting? This determines defaults for networking,
replicas, model pinning, retention, Defender, cost guardrails, and DR.
demo-sandbox — throwaway RG, public ingress, single replica,
preview model OK, no Defender, no DR. (default — safe to pick when
unsure)
customer-pilot — short-lived in the target subscription, GA-pinned
model, 90d retention, no Defender, no DR.
production-bound — meant for sustained operation. Triggers the
production-grade checklist below.
Choose [demo-sandbox / customer-pilot / production-bound]:
T-0 operator-blocking checks (foreground these for customer-pilot and production-bound)
Before letting Phase 2 generate runtime files, surface the items the
operator MUST confirm now — discovering them at azd up time is the
failure mode this gate exists to prevent:
Azure RBAC for selected posture — required role assignments
confirmed (production-bound typically needs Contributor + Network
Contributor for PE + Defender Plan Admin)
Tenant / subscription / region — az account show matches
expectation; per-tenant AZURE_CONFIG_DIR set per the
azure-tenant-isolation skill
Quota / capacity / model availability — preflight
az cognitiveservices usage list for the selected model in the chosen
region
Public ingress vs private networking — choose now, not at provision
time. Private requires VNet + Private Endpoints (deferred today)
Secrets / API-key exception — if any, requires Key Vault and
breaks the keyless mandate; record explicitly in posture file
Owner / on-call contact — recorded for production-bound handoff
Production-grade checklist (surfaced for customer-pilot and production-bound)
Walk the operator through each row of the table above. For each row,
record their choice and tag with supported-now (composer/module already
exposes the param) or deferred (recorded in posture, NOT auto-implemented
this PR — surfaces as <!-- TODO(posture): ... --> in main.bicep).
Phase 5 / 6 hooks read this file later and materialize azd env vars
from supported-now rows. deferred rows surface as TODO comments in
main.bicep (see Phase 6 Step 1.7).
Re-run contract
When Phase 1.5 runs and specs/deployment-posture.md and/or SPEC § 11f
already exist:
Readspecs/deployment-posture.md if present.
If both SPEC § 11f and the posture file exist: diff them. On conflict,
surface the conflict and ask which wins (do NOT silently trust either).
When they agree (or only one is present), proceed silently.
If only the posture file exists and SPEC § 11f is silent: accept the
posture file as canonical and proceed non-interactively to Phase 2.
Do NOT re-render the checklist and do NOT prompt — this is the contract
the CI / non-interactive override pattern relies on (see Path 1 #2).
Operator-initiated tweak only: if the operator explicitly asks to
change a row (interactive session, not CI), re-render the checklist with
current values pre-filled and only re-prompt rows being changed.
Cross-references
Production-bound posture biases Phase 7 (Citadel handoff) toward
"yes, ask" — a governance hub is the norm for prod. See Phase 7 § Path 2.
Phase 6 (Module Composer) reads specs/deployment-posture.md and passes
posture-sensitive params to Bicep modules ONLY for supported-now rows.
deferred rows become TODO comments. See Phase 6 § Step 1.7.
Phase 2: Generate Deployment Artifacts
Kratos-export mode gate. If src/hosted-agent/ and use-cases/<x>/ are
present (see Phase 1.0), skip Phase 2 entirely — the Dockerfile,
main.py, pyproject.toml, agent.yaml, and azure.yaml were shipped by
Kratos's exporter and must not be regenerated. Run the enrichment steps in
references/kratos-export-mode.md
(resolve the skills root under use-cases/<x>/skills/, backfill
use-cases/<x>/evals/, validate selectors) and resume at Phase 3.
Workflow model gate. If SPEC § 11e sets workflow_model: "workflow",
prefer the threadlight-workflow skill when it is installed (check
/skills list first). It generates container.py, executors/,
workflow_graph.py, Dockerfile, and pyproject.toml in the same
src/agent/ layout that Phase 5-6 expects; resume at Phase 3 (Validate)
after it completes.
If threadlight-workflow is NOT installed, do NOT stop, retry-loop, or
hunt the repo for it — fall back to the Phase 2 agent container path below.
The MAF Agent runtime executes the same multi-step logic via agent-driven
tool orchestration; only the deterministic workflow-graph optimization (fixed
executor order, durable pause points) is deferred, which is acceptable for a
pilot. Emit exactly one line noting the fallback (workflow_model=workflow but threadlight-workflow unavailable → generating agent container instead) and
then proceed with Phase 2 exactly as for the agent path.
If workflow_model is absent or "agent", proceed with Phase 2 below
(the existing agent container path — unchanged).
Create these files in the project root:
1. src/agent/copilot-instructions.md — Agent System Prompt
Transform AGENTS.md into a runtime system prompt:
# {Agent Name}
{Purpose from AGENTS.md — 2-3 sentences}
## Behavioral Guidelines
{Copy behavioral guidelines from AGENTS.md}
## Available Tools
{List the MCP/built-in tools with usage guidance}
## Compliance
{Copy compliance constraints from AGENTS.md}
Rules:
Keep it concise (500-1500 words) — this is injected every turn
Remove deployment metadata, local dev info, tables that reference SKILL.md paths
Focus on WHAT the agent should DO, not how it's implemented
Include tool-use discipline directive (see reference)
2. src/agent/skills/ Directory
Skills are generated directly by threadlight-design into src/agent/skills/.
No copying needed — deploy reads them in place.
These are surfaced to the agent through SkillsProvider (recommended,
progressive disclosure) or loaded at startup by a _load_skills()
helper that appends them to instructions (legacy concat). See
foundry-hosted-agents § Skill Loading for the trade-off and a
production-tested defensive _build_skills_provider() helper.
3. src/agent/mcp-config.json — MCP Server Configuration
NOTE: This config is used by the container runtime. At startup, _load_mcp_config()
reads this file, expands ${ENV_VAR} placeholders, and creates tools via client.get_mcp_tool().
Map configured MCP servers for Foundry runtime (NOT local dev):
Built-in Toolbox tool type. No Bing resource needed. MAF only.
Code Interpreter
Foundry Toolbox — add code_interpreter to Toolbox
Computation and data processing. MAF only.
File Generation
Custom @tool — save_report writing to $HOME
XLSX (openpyxl), PDF (fpdf2), CSV/HTML (text). Downloadable via session files API + FileConsentCard in Teams. MAF only. See foundry-hosted-agents skill § File Generation.
Knowledge sources (docs, policies, KB)
Foundry IQ — Azure AI Search with agentic retrieval
For static/semi-static knowledge (policies, regulations, product docs). See foundry-iq skill. Creates Knowledge Base with query planning + citations.
API data (dynamic, transactional)
MCP ACA — custom or mock MCP server
For live data (CRM, orders, transactions). See foundry-mcp-aca skill.
Cosmos DB
MCP ACA — .NET MCPToolKit (10 tools out of the box)
See foundry-mcp-aca Option A. Deploy as src/mcp/ or shared ACA.
Azure AI Search (direct)
Foundry Toolbox or custom MCP
Use Toolbox if available, or deploy custom MCP ACA
Custom data store
Custom MCP server (deploy as ACA or Azure Functions)
Proven pattern — see foundry-mcp-aca
Knowledge vs API data: Use the spec § 7 (Knowledge Sources) vs § 5 (System Integrations)
distinction to choose:
Knowledge sources (documents, policies, search indexes) → Foundry IQ (agentic retrieval
with query planning, multi-hop reasoning, citations). See foundry-iq skill.
API data (CRM, ERP, transactional systems) → MCP server (mock or real).
See foundry-mcp-aca skill.
Cosmos DB → MCPToolKit as src/mcp/ — provides 10 tools, deploy as ACA.
Key constraints for MAF hosted agents:
No local browser — hosted agent containers are headless Python environments.
Deploy browser automation as a remote MCP server on ACA.
Foundry Toolbox is the preferred tool source — create a Toolbox with web_search
and/or code_interpreter tools. Load via client.get_toolbox("name") in container.py.
The Toolbox is an MCP endpoint managed by the platform — no infrastructure to deploy.
Session files for report output — custom @tool functions can write files to
Path.home() (agent's $HOME). Files persist across turns and are downloadable via
the session files API: GET .../sessions/{sid}/files/content?path=filename.
Foundry Toolbox Setup
Create a Toolbox via REST API (or automate in a postprovision hook):
Agents in tobacco, pharma, weapons, or other regulated domains trigger Azure OpenAI's
default content filters (severity: Medium) on legitimate queries. Create a custom RAI
policy with High severity thresholds and apply to the model deployment:
# Create policy
az rest --method PUT \
--url ".../raiPolicies/my-ci-policy?api-version=2025-10-01-preview" \
--body '{"properties":{"mode":"Blocking","basePolicyName":"Microsoft.DefaultV2","contentFilters":[
{"name":"Hate","blocking":true,"enabled":true,"severityThreshold":"High","source":"Prompt"},
{"name":"Hate","blocking":true,"enabled":true,"severityThreshold":"High","source":"Completion"},
{"name":"Violence","blocking":true,"enabled":true,"severityThreshold":"High","source":"Prompt"},
{"name":"Violence","blocking":true,"enabled":true,"severityThreshold":"High","source":"Completion"},
{"name":"Selfharm","blocking":true,"enabled":true,"severityThreshold":"High","source":"Prompt"},
{"name":"Selfharm","blocking":true,"enabled":true,"severityThreshold":"High","source":"Completion"},
{"name":"Jailbreak","blocking":true,"enabled":true,"source":"Prompt"}
]}}'# Apply to deploymentecho'{"properties":{"raiPolicyName":"my-ci-policy"}}' > /tmp/rai.json
az rest --method PATCH \
--url ".../deployments/gpt-5.4-mini?api-version=2026-03-15-preview" \
--headers "Content-Type=application/json" --body @/tmp/rai.json
Automate via postprovision hook — add to azure.yaml:
The hook script creates the Toolbox, RAI policy, and Teams manifest idempotently.
4. src/agent/container.py — Agent Runtime
Generate the container runtime based on the chosen variant (see Phase 1 § 1d).
GHCP SDK variant (default)
Copy the reference template from the ghcp-hosted-agents skill's
references/container.py and adapt:
Model provider: BYOK with DefaultAzureCredential → bearer token
Instructions: loaded from copilot-instructions.md
Skills: loaded via SkillsProvider from skills/ directory (progressive discovery)
MCP: configured via mcp_servers parameter
The runtime uses CopilotClient + InvocationAgentServerHost:
CopilotClient with BYOK auth (DefaultAzureCredential → bearer token)
SkillsProvider reads skills/ directory for progressive skill loading
MCP servers configured via mcp_servers parameter
InvocationAgentServerHost serves the Invocations protocol (SSE streaming)
Diagnostic HTTP server on import failure (keeps container alive for debugging)
MAF variant (when Toolbox or custom @tool needed)
Copy the reference template from the foundry-hosted-agents companion
skill and adapt:
The runtime uses Agent + FoundryChatClient + ResponsesHostServer:
FoundryChatClient with DefaultAzureCredential for Foundry auth
Skill loading — SkillsProvider.from_paths(skills_dir) wired via
context_providers=[skills_provider] for progressive disclosure
(recommended), or legacy _load_skills() that reads all SKILL.md
files and appends to instructions. Pick one — never both. See
foundry-hosted-agents § Skill Loading for the trade-off.
_create_mcp_tools() creates tools via MCPStreamableHTTPTool with a
parse_tool_results extractor (avoids the [] repr leak)
ResponsesHostServer(agent).run() serves the Responses protocol
Diagnostic HTTP server on import failure
Do NOT write the container runtime from scratch — always start from the reference
template in the corresponding skill.
Custom Tools (Function Tools)
Agent accepts a tools parameter for custom Python functions the agent can invoke
at runtime. This is useful for capabilities not covered by MCP — e.g., API calls,
data lookups, or computations.
Recommended approach — @tool decorator:
from agent_framework import tool
from typing import Annotated
from pydantic import Field
@tool(approval_mode="never_require")defsearch_web(
query: Annotated[str, Field(description="Search query")],
) -> str:
"""Search the web and return results."""# Call your search API herereturn"search results..."
Use Annotated[type, Field(description=...)] for parameter documentation
Tool results are returned as text to the LLM for reasoning
Tip: Custom tools are a practical workaround for missing platform capabilities.
For example, if you need web search but can't use MCP, define a custom tool that
calls a search API (Tavily, SerpAPI, etc.) directly from Python.
5. src/agent/pyproject.toml — Python Dependencies
Copy the inline pyproject.toml template below and replace __PROJECT_NAME__:
Without packages = ["src"], pip install . fails with
"Unable to determine which files to ship inside the wheel" because
hatchling can't auto-discover the project directory.
CRITICAL: Uses uv (not pip) — the prerelease = "if-necessary-or-explicit" setting
lets uv resolve prerelease packages that have explicit prerelease markers in their version
specs (e.g. >=1.0.0a260421), while keeping all other packages on GA (e.g. azure-identity
resolves to 1.25.3 GA, NOT 1.26.0b2 beta). Do NOT use "allow" — it pulls beta versions
of azure-identity and other packages unintentionally.
🛑 PROVEN-WORKING REFERENCE PINS
The recipe above is the validated working set. Earlier guidance recommended
open ranges (agent-framework-core>=1.0.0) plus an explicit
azure-ai-agentserver-responses>=1.0.0b4 — that resolves to a stack uv
accepts at install time but that crashes the container at first invocation,
returning the opaque server_error/model:"".
Mandatory rules:
Pinagent-framework-core==1.3.0 and agent-framework-foundry==1.3.0. NOT open ranges.
Drop any explicit azure-ai-agentserver-responses line. Let agent-framework-foundry-hosting
pull its own pinned transitive (==1.0.0b4 currently, but the right value for whatever
foundry-hosting wants).
Add explicit mcp>=1.10.0. agent-framework-core 1.3.0 does NOT auto-pull it, and
MCPStreamableHTTPTool will fail at runtime without it.
Include[tool.setuptools] packages = [] — uv needs it to resolve cleanly when
the project itself isn't installed (uv sync --no-install-project).
Reference: keep a canonical src/agent/pyproject.toml in each process repo
and diff against the validated working shape when in doubt.
⚠️ Dependency Pinning & Future Updates
The refreshed hosted agents preview (April 2026) uses these packages:
Copy the inline Dockerfile template below and adapt:
FROM mcr.microsoft.com/oryx/python:3.12
WORKDIR /app
# Uses uv for dependency management (prerelease support for hosting package)
# Pin uv:0.7 — uv:latest (0.8+) resolves prereleases differently and may pick
# incompatible pre-built wheels. Validated working pin.
COPY --from=ghcr.io/astral-sh/uv:0.7 /uv /uvx /bin/
COPY pyproject.toml .
RUN uv sync --no-dev --no-install-project \
&& rm -rf /root/.cache
COPY container.py .
COPY copilot-instructions.md .
COPY skills/ skills/
COPY mcp-config.json .
EXPOSE 8088
CMD [".venv/bin/python", "container.py"]
Key facts:
Use MCR base images — mcr.microsoft.com/oryx/python:3.12 (no Docker Hub rate limits on ACR Tasks builds). Do NOT use python:3.12-slim from Docker Hub — ACR Tasks hits unauthenticated pull limits.
Self-contained — NO base image dependency, NO ACR reference
Uses uv (not pip) for dependency management — prerelease = "if-necessary-or-explicit" in pyproject.toml
Port 8088 is the standard Foundry agent port
azd deploy builds the container remotely — no local Docker needed
--platform linux/amd64 only needed for local builds (Foundry runs AMD64)
Entrypoint is .venv/bin/python (uv creates a virtualenv)
ResponsesHostServer handles liveness probes natively — no HEALTHCHECK needed
7. deploy-notes.md — Deployment Guide
# Deployment Guide## Architecture
This agent deploys as a **Microsoft Foundry Hosted Agent** using the
`azd ai agent` extension. `azd up` handles everything declaratively.
## Prerequisites
- Azure CLI (`az`) and Azure Developer CLI (`azd`) installed
- `azd ai agent` extension: `azd extension install azure.ai.agents` (≥0.1.30-preview)
- Azure subscription with Contributor + **Azure AI Project Manager** on Foundry project
- Set `AZURE_TENANT_ID` in azd env: `azd env set AZURE_TENANT_ID <your-tenant-id>`
> **Posture-specific RBAC (production-bound).** If SPEC § 11f declares
> `deployment_target: production-bound` — or Phase 1.5 records the same in
> `specs/deployment-posture.md` — additional Azure RBAC may be required:
> **Network Contributor** on the VNet RG (for Private Endpoints),
> **Defender for Cloud Plan Administrator** (to enable Defender), and (when
> backup/DR is on) **Cosmos DB Backup Operator**. Confirm assignments
> before `azd up`; the production-grade checklist in Phase 1.5 lists the
> full set per chosen overrides.
> **Tenant hygiene before `azd up`.** If you work across multiple Azure
> tenants, set up per-tenant `AZURE_CONFIG_DIR` / `AZD_CONFIG_DIR` in
> the calling shell **first** — see the **`azure-tenant-isolation`**
> skill. Without isolation, an `azd up` here may silently deploy into
> whatever subscription another shell last `az account set` against.
> **Run [`threadlight-local-test` **Pattern 0 — Quickstart**](../threadlight-local-test/SKILL.md)
> before `azd up`.** It exercises the same MAF `Agent + SkillsProvider`
> wiring against `specs/sample-data/*.json` stubs — no Cosmos, no MCP
> boot — so prompts/skills/tool calls are smoke-validated locally in
> minutes, before the 20-30 min deploy round-trip.
> **`azd` has its own auth chain — `az login` is not enough.**
> `azd ai agent show`, `azd deploy`, and the rest of the `azd` family
> use **`AzureDeveloperCliCredential`**, which reads tokens from
> `$AZD_CONFIG_DIR/auth/` — completely separate from the `az` CLI
> token cache under `$AZURE_CONFIG_DIR`. Even with both env vars
> pointed at the same alias, an `az login --tenant <id>` does **NOT**
> satisfy `azd`. You must run **`azd auth login --tenant-id <id>`**
> in addition to `az login`. Symptoms when missed: `azd ai agent show`
> hangs or returns `ERROR: not logged in. Try running 'azd auth
> login'`, even though `az account show` works fine. This is the #2
> "what changed?" trap after the multi-sub gotcha — bake both into
> your shell startup script.
>
> Verified working sequence (per-shell, after the alias env vars are
> exported per `azure-tenant-isolation`):
>
> ```bash
> az login --tenant "$TENANT_ID"
> az account set --subscription "$DEFAULT_SUB"
> azd auth login --tenant-id "$TENANT_ID" # NOT optional
> az account show --query "{tenant:tenantId, sub:name}" -o table
> ```
## Deploy Steps
1. **Deploy everything with one command:**
```bash
azd env set AZURE_TENANT_ID <your-tenant-id>
azd up
Auto-assigns Azure AI User to agent identity (requires Azure AI Project Manager + AZURE_TENANT_ID)
Builds and deploys the Teams bot to ACA
Test the agent:
azd ai agent invoke "Hello! What can you do?"
Or open the Foundry portal → Agents → find your agent → Chat playground.
If you get 401 errors after deploy:
The extension's postdeploy hook auto-assigns RBAC. If it failed (missing
AZURE_TENANT_ID), manually assign roles — see Identity & RBAC reference.
If the bot returns server_error in Teams:
Stale conversations from previous agent versions cause persistent server_error.
Type !reset in the Teams chat, or the bot auto-retries with a fresh conversation.
Grant deployer Cosmos data-plane RBAC for verification probes (mandatory if cosmos-db selected):
azd up grants the agent UAMI Cosmos data-plane access, but NOT the deployer
principal. Every post-deploy verification probe — counting rows, inspecting
kyc-audit-log to prove BR-XXX persistence, checking kyc-cases for stuck
case_ids — fails Forbidden — principal does not have RBAC permissions to perform action Microsoft.DocumentDB/databaseAccounts/readMetadata until you
manually grant it. Verified across recent pilots. Add this as the FIRST step
of any post-deploy verification runbook:
ME=$(az ad signed-in-user show --query id -o tsv)
az cosmosdb sql role assignment create \
-g "$AZURE_RESOURCE_GROUP" -a "$AZURE_COSMOS_ACCOUNT_NAME" \
--role-definition-id 00000000-0000-0000-0000-000000000001 \
--principal-id "$ME" --scope "/"sleep 25 # RBAC propagation
Use role 00000000-0000-0000-0000-000000000001 = Cosmos DB Built-in Data Reader (read-only is sufficient for verification; for write probes use
00000000-0000-0000-0000-000000000002 = Data Contributor). For a permanent
fix, add this to the postdeploy.py hook in infra/scripts/.
Cosmos firewall pilot-posture (mandatory if cosmos-db selected) — see foundry-mcp-aca SKILL.md § "Cosmos firewall + ACA egress" for the full runbook. Quick check:
az cosmosdb show -g "$AZURE_RESOURCE_GROUP" -n "$AZURE_COSMOS_ACCOUNT_NAME" \
--query "{publicNetAccess:publicNetworkAccess,ipRules:ipRules,bypass:networkAclBypass}" -o json
If publicNetAccess: Disabled OR ipRules: [] (and ACA-side writes are
failing), apply the runbook from foundry-mcp-aca. The azd-patternscosmos-db.bicep module defaults to pilotPosture=true to avoid this trap
on fresh pilots; the ipRules array still needs the ACA egress IP added
post-deploy (extracted from the ca-*-cosmos-mcp-* ACA logs).
Bot Implementation Notes
See the foundry-teams-bot skill for complete bot implementation — code patterns,
file sending, user identity, Bicep wiring, re-provision safety, and troubleshooting.
Key rule: bot MUST use get_openai_client(agent_name=...) — NOT the old agent_reference pattern.
Authentication
KEYLESS ONLY — never use API keys for Azure services
Each hosted agent gets a dedicated Entra identity at deploy time (platform-managed)
FoundryChatClient / CopilotClient uses DefaultAzureCredential in the container
Shared UAMI for all other resources: Bot ACA, MCP ACA, postprovision hooks, and
any other deployed resource should share one User-Assigned Managed Identity:
Created by infra/bot/uami.bicep (or a shared infra/identity/uami.bicep)
Assigned to: Bot ACA, MCP ACA, and any other ACA/Function
AZURE_CLIENT_ID env var set on all ACAs pointing to the shared UAMI
RBAC: assign Azure AI User on Foundry account + project, Cognitive Services OpenAI User,
plus any data-plane roles (Cosmos, Search, etc.) to this single UAMI
Azure AI Project Manager role required at project scope to deploy
Why one shared UAMI? Multiple system-assigned MIs mean multiple RBAC assignments
to manage. A single shared UAMI simplifies RBAC, Bicep, and troubleshooting —
one identity, one set of role assignments, one place to debug auth failures.
Health Probes
ResponsesHostServer handles liveness and readiness probes natively — no custom
middleware needed.
Monitoring & Telemetry
The Bicep scaffold creates Application Insights + Log Analytics workspace when
ENABLE_MONITORING=true (default). But you also need to connect AppInsights to
the Foundry project for eval telemetry and agent tracing to work.
APPLICATIONINSIGHTS_CONNECTION_STRING — RESERVED by the platform for hosted
agents. Do NOT set it in agent.yaml — the platform injects it automatically.
What you must do manually (or via postprovision hook)
Create an AppInsights connection on the Foundry ACCOUNT (not project):
The Foundry account needs an AppInsights connection so that agent traces
and eval telemetry appear in the Foundry portal. This is NOT automatic.
Key details:
Category is AppInsights (NOT ApplicationInsights)
Target is the ARM resource ID (NOT the connection string)
Metadata must include ApiType: Azure
Connection is at account level, not project level
# Via Azure REST API
ACCOUNT_SCOPE="/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<account>"
APPINSIGHTS_ARM_ID="/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Insights/components/<appinsights>"
az rest --method PUT \
--url "${ACCOUNT_SCOPE}/connections/<connection-name>?api-version=2025-10-01-preview" \
--body '{
"properties": {
"category": "AppInsights",
"target": "'${APPINSIGHTS_ARM_ID}'",
"authType": "AAD",
"metadata": {
"ApiType": "Azure"
}
}
}'
RBAC for telemetry — ALL identities need access:
Identity
Role
Scope
Why
Agent instance identity
Monitoring Metrics Publisher
Application Insights
Agent container writes telemetry
Agent blueprint identity
Monitoring Metrics Publisher
Application Insights
Platform internal telemetry
Project managed identity
Log Analytics Data Reader
Log Analytics workspace
Read telemetry for evaluations
Shared UAMI (bot, MCP)
Log Analytics Data Reader
Log Analytics workspace
Postdeploy hooks read telemetry
Get agent identities from azd ai agent show → instance_identity.principal_id
and blueprint.principal_id. Assign to both.
APPINSIGHTS_ID="/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Insights/components/<appinsights>"# Agent identities (both instance + blueprint) — WRITE telemetry
az role assignment create --assignee <INSTANCE_PRINCIPAL_ID> --role "Monitoring Metrics Publisher" --scope $APPINSIGHTS_ID
az role assignment create --assignee <BLUEPRINT_PRINCIPAL_ID> --role "Monitoring Metrics Publisher" --scope $APPINSIGHTS_ID# Project MI — READ telemetry for evals
az role assignment create --assignee <PROJECT_MI_PRINCIPAL_ID> --role "Log Analytics Data Reader" --scope $LOG_ANALYTICS_ID
OpenTelemetry in container (MANDATORY — guarded init only)
Why this is mandatory, not optional. The platform's
APPLICATIONINSIGHTS_CONNECTION_STRING auto-injection is
best-effort, not contractual — it can silently fail (for example,
in some regions: AppIn account-level connection persisted
with credentials: null after AAD-rejected → ApiKey-silent-drop;
platform never injected the env var). When it fails AND container.py
calls raw configure_azure_monitor() at the top of main(), the SDK
raises ValueError, the container exits before ResponsesHostServer
binds, and Foundry returns server_error/model:"" on every smoke —
with ZERO telemetry to debug it (because telemetry init is what
crashed). The agent itself is fine; only the unguarded init kills it.
Hosted-agent container.py MUST wrap the init defensively —
use init_telemetry() from foundry-observability's
references/python/otel_init.py, OR inline an 8-line equivalent
that no-ops on missing env / SDK import / SDK raise. See the
foundry-observability skill (gap rows O-011 / O-012) for the
full forensic and reference helper.
And initialize in container.py — guarded helper, NOT raw call:
# At module topimport logging, os
logger = logging.getLogger(__name__)
def_init_telemetry() -> None:
"""Guarded telemetry init — never lets a missing/broken AppIn kill the container."""
conn = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING")
ifnot conn:
logger.info("APPIN connection string not set — telemetry disabled (agent functional)")
returntry:
from azure.monitor.opentelemetry import configure_azure_monitor
configure_azure_monitor(connection_string=conn)
except Exception as exc: # noqa: BLE001
logger.warning("App Insights telemetry init skipped: %s (agent functional)", exc)
defmain() -> None:
_init_telemetry() # FIRST line — guarded, never raises# ... rest of main() ...
Anti-pattern (will silently kill your PoC):configure_azure_monitor() as the first line of main() with no
try/except. Causes the entire PoC to fail on any telemetry-platform
glitch. The agent works fine without telemetry — let it.
Note: The platform attempts to inject APPLICATIONINSIGHTS_CONNECTION_STRING
into hosted agent containers automatically (best-effort) — do NOT declare
it in agent.yaml, it is reserved. create_version rejects it with
invalid_request_error (request IDs are available in platform logs).
Evaluations
See the foundry-evals skill for the complete evaluation guide — two-phase
invoke+score pattern, 6 built-in evaluators, RBAC for judge models, and score interpretation.
Generated eval files
If specs/SPEC.md § 9 contains evaluation scenarios (S-XXX), generate:
Each line maps directly to a scenario row in the spec.
tests/run_evals.py
Invoke + score script using the foundry-evals two-phase pattern.
MUST include three Windows-survival defenses from day one — every
PoC that has skipped them has lost full eval batches mid-run (see
foundry-evals SKILL § "Eval scripts on Windows: cp1252 trap"):
UTF-8 stdout wrap so the agent's → / em-dashes don't crash
safe() ASCII-strip on every agent string before printing
Incremental JSON writes after each scenario, not at the end
"""Run eval scenarios against the deployed agent.
Windows-survival defenses baked in (DO NOT REMOVE):
- UTF-8 stdout wrap so the agent's non-ASCII output cannot crash us
- safe() ASCII-strip when printing agent strings
- Incremental JSON writes so a mid-run crash keeps prior results
"""import io, json, sys, time
from pathlib import Path
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8",
errors="replace", line_buffering=True)
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8",
errors="replace", line_buffering=True)
from azure.ai.projects import AIProjectClient
from azure.identity import DefaultAzureCredential
from openai import APIError
PROJECT_ENDPOINT = "<from azd env>"
AGENT_NAME = "<from agent.yaml>"
RESULTS_PATH = Path("tests/eval-results.json")
defsafe(s, n=None):
if s isNone:
return""if n:
s = s[:n]
return s.encode("ascii", errors="replace").decode("ascii")
project = AIProjectClient(endpoint=PROJECT_ENDPOINT,
credential=DefaultAzureCredential(),
allow_preview=True)
oai = project.get_openai_client(agent_name=AGENT_NAME)
dataset = [json.loads(line) for line in
Path("tests/eval_dataset.jsonl").read_text(encoding="utf-8").splitlines()
if line.strip()]
# Cold-start retry loop (foundry-evals warmup pattern)for attempt inrange(1, 5):
try:
w = oai.responses.create(input="Reply 'OK' if ready.", stream=False)
if w.status == "completed":
breakexcept Exception as e:
print(f"[warmup] attempt={attempt} EXC: {safe(str(e), 200)}")
if attempt < 4:
time.sleep(60)
results = []
RESULTS_PATH.parent.mkdir(parents=True, exist_ok=True)
for item in dataset:
sid = item["id"]
print(f"--- {sid} [{item.get('category', '')}] ---")
t0 = time.time()
rec = {"id": sid, **item}
try:
r = oai.responses.create(input=item["query"], stream=False)
elapsed = time.time() - t0
out_text = ""for o in (r.output or []):
for c in (getattr(o, "content", None) or []):
t = getattr(c, "text", None)
if t:
out_text += t.value ifhasattr(t, "value") elsestr(t)
rec.update({"status": r.status, "elapsed_s": round(elapsed, 1),
"response_id": r.id, "output_len": len(out_text),
"output_first_400": safe(out_text, 400)})
print(f" status={r.status} elapsed={elapsed:.1f}s")
print(f" output_first_400: {safe(out_text, 400)!r}")
except APIError as e:
rec.update({"status": "api_error", "code": e.code,
"elapsed_s": round(time.time() - t0, 1)})
except Exception as e:
rec.update({"status": "exception", "exc_type": type(e).__name__,
"exc_msg": safe(str(e), 300),
"elapsed_s": round(time.time() - t0, 1)})
results.append(rec)
# Write after EVERY scenario - mid-run crash keeps prior results
RESULTS_PATH.write_text(json.dumps(results, indent=2), encoding="utf-8")
time.sleep(5)
print(f"\n{len(results)} scenarios invoked. Run foundry-evals to score.")
Run with python -X utf8 tests/run_evals.py (or set PYTHONUTF8=1 /
PYTHONIOENCODING=utf-8 in the parent shell) for a 4th defense layer.
tests/invoke_agent.py
Simple smoke test — invoke the deployed agent with a single message (already covered).
Phase 3: Validate & Auto-Review (mandatory)
After generating all files, walk through this checklist item by item. Every
generated file must be accounted for. This is the single most important step —
if you skip it, broken or missing files ship to the user.
Output Checklist
Check every file. Mark each ✅ or fix before presenting.
src/agent/ — Hosted agent container
src/agent/container.py — exists, matches chosen runtime (GHCP or MAF)
src/agent/Dockerfile — uses mcr.microsoft.com/oryx/python:3.12, uv sync, copies all agent files
src/agent/pyproject.toml — correct deps for chosen variant, prerelease = "if-necessary-or-explicit"
src/bot/app.py — aiohttp server with MsalConnectionManager
src/bot/Dockerfile — mcr.microsoft.com/oryx/python:3.12, port 80
src/bot/requirements.txt — includes microsoft-agents-* + openai
src/bot/build_manifest.py — replaces all manifest tokens; MUST fail loudly if BOT_APP_ID env var is missing or still a placeholder (e.g. <uami-client-id>) — silent fallback to a placeholder produces a zip that passes azd deploy but fails Teams schema validation at sideload time with String "<uami-client-id>" does not match regex pattern. Guard: if not bot_id or bot_id.startswith("<"): raise SystemExit("BOT_APP_ID not set")
src/bot/teams_package/manifest.json — has __BOT_APP_ID__ placeholder tokens ready for postprovision (NEVER literal <uami-client-id> — use double-underscore __ tokens that are obviously wrong if leaked)
Bot ACA env block has ALL FOUR CONNECTIONS__SERVICE_CONNECTION__SETTINGS__* vars (CLIENTID, TENANTID, AUTHORITYENDPOINT, AUTHTYPE=UserManagedIdentity) — missing AUTHTYPE is a silent-killer bug: bot looks healthy from outside, JWT probe passes, every real Teams message returns HTTP 500 with AADSTS7000216. See foundry-teams-bot skill § Bicep snippet.
Bot Service appType: UserAssignedMSI with msaAppId = UAMI clientId AND msaAppMSIResourceId = UAMI ARM ID
(Skip this section entirely if Teams not needed)
Root config files (MUST be at repo root — azd requires this)
agent.yaml and azure.yaml stay at repo root, NOT under src/.
The azd ai agent extension and azd CLI look for them at the repo root.
Only the source code (container.py, Dockerfile, skills, etc.) goes under src/.
The project: ./src/agent field in azure.yaml tells azd where the Dockerfile is.
agent.yaml must ALSO be copied to src/agent/ — the extension reads it from
root for agent creation, but the container build context needs it in the service dir
for the hosted agent version to resolve correctly. Keep both in sync.
container.py wraps telemetry init defensively (no raw configure_azure_monitor() at module/main scope — use _init_telemetry() or init_telemetry() helper that no-ops on missing env / SDK import / SDK raise; see foundry-observability gap O-011)
Use the consolidated threadlight-safe-check skill for the
mechanical implementation of this check (and Phase 3.5 below). The
manual matrix below is preserved for documentation; the canonical
automation is:
python3 tests/safe_check.py --phase pre-deploy
Wire it as an azd hooks predeploy so missing services abort the
docker build before wasting an ACR push.
Why this exists. A recent investigation-style PoC shipped
with infra/bot/aca.bicep, infra/bot/bot-service.bicep, and a
src/workspace/index.htmlbutazure.yaml only declared two services (agent+mcp), infra/main.biceponly wiredmcpApp, and the workspace had no Dockerfile. Result: SPEC § 11c said aca-bot: yesandaca-job: yes; deployment ended up with **0 bot resources, 0 jobs, 0 workspace ACAs** and the deploy still
reported success. threadlight-safe-check --phase pre-deploy would
have caught this.
For every yes row in SPEC § 11c, walk this matrix:
Selector
Must exist in azure.yaml services
Must be referenced from infra/main.bicep
Must have source under src/
aca-mcp
host: containerapp, project: ./src/mcp
module mcpApp '<host>/container-app.bicep'
src/mcp/Dockerfile + server.py
aca-bot
host: containerapp, project: ./src/bot
module botAca 'bot/aca.bicep' AND module botService 'bot/bot-service.bicep'
For every yes selector, all three columns MUST be checked. If any
is missing: STOP, fix the gap, do not proceed to azd up.
Bicep-module orphan check (MANDATORY)
For every infra/<dir>/*.bicep file in the repo, run:
# From repo rootfor f in $(find infra -name '*.bicep' -not -path '*/core/*' -not -path '*/modules/*'); do
base=$(basename"$f" .bicep)
if ! grep -q "module .*'.*$base\.bicep'" infra/main.bicep; thenecho"ORPHAN: $f is not referenced from infra/main.bicep"fidone
Orphan modules confuse future readers ("is this needed? was the deploy
broken?"). Either wire them in or delete them. No middle
ground. A recent PoC carried orphan
infra/bot/aca.bicep and infra/bot/bot-service.bicep for an entire
deploy cycle they looked deployed when readinginfra/`, but
weren't.
src/-folder orphan check (MANDATORY)
Mirror of the above for source code. For every src/<dir>/:
Source folder
Must be declared in azure.yaml services
Action if not
src/agent/
host: azure.ai.agent
required ` always present
src/mcp/
host: containerapp (named mcp)
wire it OR delete src/mcp/
src/bot/
host: containerapp (named bot)
wire it OR delete src/bot/
src/workspace/
host: containerapp (named workspace) AND must have Dockerfile
wire it OR explicitly mark in SPEC § 8b as "demo-only static page" with no ACA hosting
src/jobs/<name>/
host: containerapp.job (named <name>) OR postdeploy az containerapp job create
wire it OR delete
Same rule: orphan source folders are a deploy bug. Either ship them or
remove them.
If any check fails: fix it before presenting. Do not leave broken artifacts.
⚠️ If azd up fails or the container doesn't respond, CHECK LOGS
FIRST before retrying. Run az containerapp logs show -g <rg> -n <app> --type system --tail 20 for infrastructure events (port mismatch,
probe failures, image pull errors) and --type console for application
crashes. See azd-patterns § "ACA Container Debugging" for the full
playbook. Never retry blind.
Canonical implementation: threadlight-safe-check skill. Invoke as
python3 tests/safe_check.py --phase post-deploy immediately after
azd up returns 0 (and wire as azd hooks postdeploy). The detailed
step-by-step below is preserved for understanding what the gate does;
in practice run the consolidated CLI rather than reimplementing.
Why this is non-negotiable. "PoC complete" is NOT the same as
"azd up returned 0". It means every Azure resource declared by
SPEC § 11c is in az resource list, every channel declared by SPEC
§ 8 is reachable, and every scheduled job is running. Without
this gate, a recent PoC was reported "deployed
and evaluated" with aca-bot, aca-job, and workspace-ui all
silently missing. Run this gate before announcing success.
Step 1 ` capture deployed state
# Make sure azure-tenant-isolation env vars are set first
RG=$(azd env get-value AZURE_RESOURCE_GROUP)
az resource list -g "$RG" \
--query "[].{type:type, name:name}" -o json > tests/deployed-resources.json
az containerapp list -g "$RG" \
--query "[].{name:name, fqdn:properties.configuration.ingress.fqdn, state:properties.runningStatus}" \
-o json > tests/deployed-containerapps.json
az containerapp job list -g "$RG" \
--query "[].{name:name, schedule:properties.configuration.scheduleTriggerConfig.cronExpression}" \
-o json > tests/deployed-jobs.json
Step 2 ` build expected list from SPEC
For every yes row in SPEC § 11c, look up the expected resource
type(s):
Microsoft.App/containerApps (1 named *-mcp-* or ca-mcp-*)
aca-bot
Microsoft.App/containerApps (1 named *-bot-*) ANDMicrosoft.BotService/botServices
aca-job
Microsoft.App/jobs (1 per cron entry)
workspace-ui
Microsoft.App/containerApps (1 named *-workspace-* or *-ui-*)
event-grid
Microsoft.EventGrid/topics (or systemTopics)
service-bus
Microsoft.ServiceBus/namespaces
key-vault
Microsoft.KeyVault/vaults (only if explicitly yes ` keyless-by-default)
storage-blob
Microsoft.Storage/storageAccounts
foundry-iq-index
Microsoft.Search/searchServices (named *-iq-*) AND azd env get-value FOUNDRY_IQ_KB_NAME resolves
Step 3 ` diff and assert
# tests/postdeploy_gate.py ` make this part of the deploy script.import json, sys
from pathlib import Path
deployed = json.loads(Path("tests/deployed-resources.json").read_text())
deployed_types = {r["type"] for r in deployed}
# Build expected from SPEC § 11c. Hand-maintain this list per process,# or read it from specs/manifest.json -> deployment_manifest.expected_resource_types
expected = {
"Microsoft.CognitiveServices/accounts",
"Microsoft.DocumentDB/databaseAccounts",
"Microsoft.Search/searchServices",
"Microsoft.App/managedEnvironments",
"Microsoft.App/containerApps", # mcp + bot + workspace"Microsoft.App/jobs", # deadline-watcher cron"Microsoft.BotService/botServices",
"Microsoft.ManagedIdentity/userAssignedIdentities",
"Microsoft.ContainerRegistry/registries",
"Microsoft.Insights/components",
}
missing = expected - deployed_types
if missing:
print(f"GAP: missing resource types: {missing}")
sys.exit(1)
# Per-app instance checks for the ACAs (counts matter ` 3 ACAs expected)
acas = json.loads(Path("tests/deployed-containerapps.json").read_text())
aca_names = {a["name"] for a in acas}
required_aca_patterns = {"mcp": False, "bot": False, "workspace": False}
for n in aca_names:
for k in required_aca_patterns:
if k in n.lower(): required_aca_patterns[k] = True
unmet = [k for k, v in required_aca_patterns.items() ifnot v]
if unmet:
print(f"GAP: missing required ACA roles: {unmet}")
sys.exit(1)
print("OK - post-deploy completeness gate passed")
Step 4 ` channel reachability
For every Human Interaction channel in SPEC § 8, run a smoke check: