The backend is a pure HTTP dispatcher (FastAPI). It has no local AI runtime. All specialist reasoning runs in four independent Foundry Hosted Agent containers.
Frontend (Next.js / ACA)
└── POST /api/review/stream (SSE)
└── FastAPI Backend / Orchestrator (ACA)
│
├─── [Docker Compose — local dev] ──────────────────────────────────────
│ POST http://agent-{name}/responses (HOSTED_AGENT_*_URL)
│ → Clinical / Compliance / Coverage / Synthesis Container
│
└─── [Foundry Hosted Agents — refreshed preview (azd up)] ──────────
AIProjectClient(allow_preview=True)
.get_openai_client(agent_name="clinical-reviewer-agent")
→ POST {project_endpoint}/agents/{name}/endpoint/protocols/openai/v1/responses
Authorization: Bearer <DefaultAzureCredential>
→ Foundry routes to per-agent dedicated container
Each agent container runs the Microsoft Agent Framework refreshed-preview
stack (agent-framework-core + agent-framework-foundry +
agent-framework-foundry-hosting) and is hosted by
agent_framework_foundry_hosting.ResponsesHostServer. Agents are registered
with Microsoft Foundry by azd deploy itself — each agent has a
host: azure.ai.agent entry in the repo-root azure.yaml that the
azd ai agent extension uses to ACR-build the image, push it, and call
create_version() on the project. A postdeploy hook
(scripts/grant_agent_rbac.py) then grants Azure AI User to each agent's
per-instance Application identity so it can serve the Responses API.
All five MCP servers are wired in-container via
MCPStreamableHTTPTool. The refreshed Hosted Agents preview rejects the
legacy tools: [{type: mcp, ...}] definition entries, so the older
Foundry-managed MCPTool model is no longer used. See
architecture.md § "MCP Integration" for
the full rationale.
| MCP Server | Wiring | Where defined |
|---|---|---|
| ICD-10 codes | In-container MCPStreamableHTTPTool |
agents/clinical/main.py (URL from MCP_ICD10_CODES env var) |
| Clinical Trials | In-container MCPStreamableHTTPTool |
agents/clinical/main.py (URL from MCP_CLINICAL_TRIALS env var) |
| NPI Registry | In-container MCPStreamableHTTPTool |
agents/coverage/main.py (URL from MCP_NPI_REGISTRY env var) |
| CMS Coverage | In-container MCPStreamableHTTPTool |
agents/coverage/main.py (URL from MCP_CMS_COVERAGE env var) |
| PubMed | In-container MCPStreamableHTTPTool |
agents/clinical/main.py (URL from MCP_PUBMED env var) |
Four of the five MCPs (ICD-10, Clinical Trials, NPI Registry, CMS Coverage)
live on Anthropic's healthcare gateway at hcls.mcp.claude.com; PubMed
has its own subdomain at pubmed.mcp.claude.com. All five are stateless
HTTPS endpoints — no session-expiry workarounds are required. Each agent
injects User-Agent: claude-code/1.0 via a shared httpx.AsyncClient as
defensive insurance because the Cloudflare gateway blocks the default
Python-urllib/* UA but accepts any other identifier.
In both Foundry deployment (agent.yaml environment_variables) and
docker-compose local dev (docker-compose.yml), the MCP_* env vars are
set with the same Anthropic-hosted URLs as defaults, so the in-container
wiring behaves identically across modes.
Each agent loads its SKILL.md via SkillsProvider:
skills_provider = SkillsProvider.from_paths(
str(Path(__file__).parent / "skills")
)
agent-framework-core1.2.0 moved file-based construction fromSkillsProvider(skill_paths=...)(initial preview) to theSkillsProvider.from_paths(...)factory — the old form raisesTypeErrorat import time and surfaces as HTTP424 session_not_ready.
SKILL.md files live alongside the agent:
agents/
clinical/skills/clinical-review/SKILL.md # ICD-10 validation, clinical extraction (< 60% warning), literature + trials
coverage/skills/coverage-assessment/SKILL.md # Provider NPI, specialty-procedure match, CMS policy, criteria mapping
compliance/skills/compliance-review/SKILL.md # 10-item checklist (items 9: NCCI, 10: service type are non-blocking)
synthesis/skills/synthesis-decision/SKILL.md # Gate rubric, weighted confidence, synthesis_audit_trail output
Each agent container declares a local Pydantic model in schemas.py and
passes it via the default_options parameter on Agent:
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
chat_client = FoundryChatClient(project_endpoint=..., model=..., credential=..., allow_preview=True)
agent = Agent(
client=chat_client,
name="clinical-reviewer-agent",
id="clinical-reviewer-agent", # Must match registered name for Foundry Traces
tools=[...],
default_options={"response_format": ClinicalResult, "store": False},
)
ResponsesHostServer(agent).run()The store: False option is required because the refreshed preview manages
conversation history at the platform level. The framework enforces the
response_format schema as a token-level JSON constraint at inference time —
no post-processing or regex extraction needed. The backend dispatcher reads
the text payload from the OpenAI SDK response:
# hosted_agents.py
output_text = response.output_text
return json.loads(output_text)The Pydantic models live in each agent container:
| Agent | Schema file | Root model |
|---|---|---|
| Clinical | agents/clinical/schemas.py |
ClinicalResult |
| Compliance | agents/compliance/schemas.py |
ComplianceResult |
| Coverage | agents/coverage/schemas.py |
CoverageResult |
| Synthesis | agents/synthesis/schemas.py |
SynthesisOutput (includes synthesis_audit_trail: str — JSON-encoded audit trail with gate_results and confidence_components; parsed back to dict by the orchestrator) |
Phase 1 (parallel): Compliance + Clinical agents
Phase 2 (sequential): Coverage agent (receives clinical findings)
Phase 3: Synthesis agent (receives all three results)
Phase 4: Audit trail + PDF generation
| Mechanism | Where | What it does |
|---|---|---|
| Result validation | _validate_agent_result() |
Checks expected top-level keys |
| Automatic retry | _safe_run() |
Retries once if validation fails |
| SSE status warnings | Phase events | Reports status "warning" for incomplete results |
| Tool result normalization | _normalize_tool_result() |
Maps non-standard status values |
Gate 1: Provider NPI verification → Gate 2: Code validation → Gate 3: Medical necessity
Default to PEND at any uncertain gate. Never DENY in LENIENT mode.
- Review completes → stored in-memory (reviewed via
GET /api/reviews) - Frontend shows Accept / Override panel
POST /api/decisionprevents double-decisions (409)- Generates thread-safe authorization number (
PA-YYYYMMDD-XXXXX) - Produces notification letter (approval or pend) in text and PDF
Letter types:
- Approval — auth number, 90-day validity, coverage criteria met, clinical rationale
- Pend — confidence level, missing documentation, 30-day deadline, appeal rights
Pre-flight step before agents execute:
- Format validation — regex for CPT (5-digit) or HCPCS (letter + 4 digits)
- Curated lookup — ~30 common PA-trigger codes
- Results injected into synthesis prompt for Gate 2
The frontend includes a "Load Sample Case" button for a CT-guided transbronchial lung biopsy case:
| Field | Value |
|---|---|
| Patient | John Smith, DOB 1958-03-15 |
| Provider NPI | 1720180003 (active pulmonologist) |
| ICD-10 codes | R91.1, J18.9, R05.9 |
| CPT code | 31628 |
| Insurance ID | MCR-123456789A |
All five processes — the FastAPI backend and all four agent containers — export
OpenTelemetry traces and metrics to Azure Application Insights via
azure-monitor-opentelemetry. Agent traces are also visible in the Foundry
portal's built-in Traces view when App Insights is linked to the Foundry project.
| Process | OTel service name | What it instruments |
|---|---|---|
| FastAPI backend | prior-auth-backend (set via OTEL_SERVICE_NAME) |
HTTP requests/responses, outgoing httpx calls to agents, logs, exceptions, live metrics |
| Clinical agent | azure.ai.agentserver (hard-coded by the host) |
MAF invoke_agent, chat, execute_tool spans, token metrics. Per-agent identity via gen_ai.agent.name (= FOUNDRY_AGENT_NAME) |
| Coverage agent | azure.ai.agentserver |
Same as above |
| Compliance agent | azure.ai.agentserver |
Same as above |
| Synthesis agent | azure.ai.agentserver |
Same as above |
Each process configures observability differently based on its role:
- Backend (
observability.py): Callsconfigure_azure_monitor()directly before the FastAPI app starts. This is the standard Azure Monitor SDK pattern for non-MAF applications. - Agent containers: Do NOT set
OTEL_SERVICE_NAME(the host derives the service name from the platform-injectedFOUNDRY_AGENT_NAME). WhenAPPLICATIONINSIGHTS_CONNECTION_STRINGis present, each agent'smain.pycallsagent_framework.observability.enable_instrumentation(...)BEFORE constructing theAgentandResponsesHostServer. This is the supported pattern from the Microsoft Agent Framework observability docs — without it, MAF'sgen_ai.*span emission code paths stay dormant and the App Insights pipeline only carries platform-side spans (model calls, MCP tools), missing the agent-layer spans (agent.run, tool execution lifecycle, handoffs). Agent code also bridges the legacyAPPLICATION_INSIGHTS_CONNECTION_STRING(with underscore) to the canonical no-underscore form for docker-compose parity — in production theagent.yamlenvironment_variablesblock setsOTEL_CONNECTION_STRING(the platform reserves the canonicalAPPLICATIONINSIGHTS_CONNECTION_STRINGname) andmain.pyoverwrites the malformed platform-injected value with it before the host server starts.
Note on host vs MAF instrumentation:
ResponsesHostServeritself does NOT auto-configure MAF instrumentation in its constructor — confirmed by reading the Python source. The platform exports its own spans (model invocations, MCP tool calls) regardless, but MAF's agent-layer spans are opt-in viaenable_instrumentation()(or the equivalent env varsENABLE_INSTRUMENTATION=true/ENABLE_SENSITIVE_DATA=true).
The refreshed Hosted Agents preview gives every agent a dedicated Entra
identity at deploy time (agent.instance_identity.principal_id) and the
platform injects FOUNDRY_AGENT_NAME, FOUNDRY_AGENT_VERSION, and
related dimensions into the container at runtime, so trace correlation
works without any application-side patching. The previous
_patch_trace_agent_id() monkey-patch \u2014 used in the initial preview to fix
gaps in azure-ai-agentserver-agentframework v1.0.0b17 — has been removed
from each agent's main.py.
If you observe missing gen_ai.agent.id on a span, verify:
- The
id=andname=arguments onAgent(...)match the registered agent name (e.g.clinical-reviewer-agent). - The container is being reached through its dedicated endpoint
(
{project_endpoint}/agents/{name}/endpoint/...) rather than a shared project endpoint withagent_reference(legacy initial-preview pattern).
By default, enable_sensitive_data is off in every environment. Each
agent's main.py reads the ENABLE_OTEL_SENSITIVE_DATA env var and passes it
through to enable_instrumentation(enable_sensitive_data=...). When false
(the default), spans contain only metadata: model name, token counts, latency,
status, error type, MCP tool name + duration. Prompts, completions, and
tool-call arguments are NOT attached to spans.
⚠️ PHI safety control — leave OFF in any shared, staging, or production environment. WhenENABLE_OTEL_SENSITIVE_DATA=true, full LLM prompts, completions, and tool-call arguments are attached togen_ai.*spans and shipped to Application Insights — meaning patient identifiers, ICD-10 codes, clinical notes, and policy details would land in App Insights as plaintext. The env var is intentionally NOT set in anyagent.yamland NOT exposed as a Bicep parameter, so it cannot be enabled in Azure without a code change
- redeploy. For ad-hoc PA content inspection, use the Foundry portal's built-in Tracing tab (RBAC-gated to project members) instead.
For local debugging only:
export ENABLE_OTEL_SENSITIVE_DATA=true
docker compose upThe backend sets OTEL_SERVICE_NAME=prior-auth-backend; the four agent
containers all share the host-provided service name azure.ai.agentserver
but are distinguished by the gen_ai.agent.name span attribute (sourced
from FOUNDRY_AGENT_NAME). Application Insights renders this topology:
prior-auth-backend
└──► azure.ai.agentserver (4 nodes when grouped by gen_ai.agent.name:
clinical-reviewer-agent,
compliance-agent,
coverage-assessment-agent,
synthesis-agent)
Edges are drawn from the backend's auto-instrumented outgoing httpx dependency spans. W3C trace context headers propagate across process boundaries so App Insights stitches the end-to-end call graph automatically — no manual correlation ID wiring is needed.
If you want each agent to appear as a separate Application Map node, group or
filter the dependency view by gen_ai.agent.name (KQL: dependencies | summarize by tostring(customDimensions['gen_ai.agent.name'])). The previous per-agent
OTEL_SERVICE_NAME override is no longer used — the refreshed host hard-codes
azure.ai.agentserver as the service name and that value cannot be changed via
environment variable.
prior_auth_review (request_id)
├── phase_1_parallel
│ ├── compliance_agent_dispatch
│ └── clinical_agent_dispatch
├── phase_2_coverage
│ └── coverage_agent_dispatch
├── phase_3_synthesis
│ └── synthesis_agent_dispatch
└── phase_4_audit
| Span | Emitted by | Key attributes |
|---|---|---|
invoke_agent |
MAF | agent name, status |
chat |
MAF | model deployment, turn index |
execute_tool |
MAF | tool name, tool result status |
These spans are children of the backend *_agent_dispatch dependency spans,
creating an end-to-end trace from HTTP request → backend orchestration → agent
tool calls.
| Span | Key attributes |
|---|---|
prior_auth_review |
request_id |
phase_1_parallel |
compliance_status, clinical_status |
phase_2_coverage |
coverage_status |
phase_3_synthesis |
recommendation, confidence |
phase_4_audit |
confidence, confidence_level |
Set the same connection string in all five containers (Bicep injects this
automatically from the shared monitoring module output):
APPLICATION_INSIGHTS_CONNECTION_STRING=InstrumentationKey=<key>;IngestionEndpoint=...Important: env var naming. The repo uses two forms of the same connection string at different layers:
| Surface | Env var name | Reason |
|---|---|---|
azd env, repo .env, azure.yaml postprovision, backend container (backend/app/config.py) |
APPLICATION_INSIGHTS_CONNECTION_STRING |
Repo legacy convention; azure-monitor-opentelemetry is invoked explicitly with this value via configure_azure_monitor(connection_string=...). |
| Agent containers (Foundry + docker-compose), Azure Monitor SDK, Azure App Service | APPLICATIONINSIGHTS_CONNECTION_STRING |
Canonical Azure Monitor / App Service name. Read by azure-monitor-opentelemetry and by the platform-side telemetry pipeline. |
Each agent's agent.yaml declares the canonical name via
OTEL_CONNECTION_STRING (a non-reserved alias of
APPLICATIONINSIGHTS_CONNECTION_STRING). For local docker-compose where the
root .env may still use the legacy underscore name, each agent's main.py
bridges it by calling
os.environ.setdefault("APPLICATIONINSIGHTS_CONNECTION_STRING", ...) and
then calls enable_instrumentation() to activate MAF telemetry. Without the
canonical name, the bridge is a no-op, enable_instrumentation() is skipped,
and the Foundry portal shows empty Trace ID / Duration / Tokens.
hosted_agents.py automatically selects the dispatch mode based on environment:
- URL set (
HOSTED_AGENT_*_URL): direct HTTP to the container — Docker Compose mode - URL empty +
AZURE_AI_PROJECT_ENDPOINTset: per-agent dedicated Foundry endpoint viaAIProjectClient(allow_preview=True).get_openai_client(agent_name=...)— production mode
Docker Compose mode — HOSTED_AGENT_*_URL vars (defaults already in docker-compose.yml):
| Agent | Variable | Default |
|---|---|---|
| Clinical | HOSTED_AGENT_CLINICAL_URL |
http://agent-clinical:8088 |
| Compliance | HOSTED_AGENT_COMPLIANCE_URL |
http://agent-compliance:8088 |
| Coverage | HOSTED_AGENT_COVERAGE_URL |
http://agent-coverage:8088 |
| Synthesis | HOSTED_AGENT_SYNTHESIS_URL |
http://agent-synthesis:8088 |
Shared: HOSTED_AGENT_TIMEOUT_SECONDS (default 180).
Foundry Hosted Agents mode — injected automatically by Bicep via azd up:
| Variable | Value |
|---|---|
AZURE_AI_PROJECT_ENDPOINT |
https://<account>.services.ai.azure.com/api/projects/<project> |
HOSTED_AGENT_CLINICAL_NAME |
clinical-reviewer-agent |
HOSTED_AGENT_COMPLIANCE_NAME |
compliance-agent |
HOSTED_AGENT_COVERAGE_NAME |
coverage-assessment-agent |
HOSTED_AGENT_SYNTHESIS_NAME |
synthesis-agent |
Token acquisition uses azure.identity.aio.DefaultAzureCredential — no manual token configuration needed.
The following RBAC roles are automatically assigned during azd up:
| Role | Principal | Scope | How Assigned | Purpose |
|---|---|---|---|---|
| Cognitive Services OpenAI User | Backend Container App managed identity | Foundry account | role-assignments.bicep (provision) |
Orchestrator calls each agent's dedicated Foundry endpoint via get_openai_client(agent_name=...) |
| AcrPull | Backend Container App managed identity | Container Registry | role-assignments.bicep (provision) |
Container Apps pulls the backend image from ACR via system-assigned MI (no admin user / passwords) |
| AcrPull | Frontend Container App managed identity | Container Registry | role-assignments.bicep (provision) |
Container Apps pulls the frontend image from ACR via system-assigned MI |
| AcrPull | Foundry project managed identity | Container Registry | role-assignments.bicep (provision) |
Foundry Agent Service pulls agent container images from ACR |
| Cognitive Services OpenAI Contributor | Foundry project managed identity | Foundry account | role-assignments.bicep (provision) |
Hosted agent containers call gpt-5.4 via the Responses API |
| Azure AI User | Foundry project managed identity | Foundry account | role-assignments.bicep (provision) |
Hosted agent containers use Foundry Agent Service data actions |
| Azure AI User | Deployer (user running azd up) |
Foundry project | az role assignment create (postprovision hook) |
The azd ai agent extension invokes the Foundry Agent Service API on behalf of the deployer |
| Azure AI Project Manager | Deployer (user running azd up) |
Foundry project | az role assignment create (postprovision hook) |
Required by the refreshed Hosted Agents preview to call create_version() on HostedAgentDefinition / PromptAgentDefinition — Azure AI User only covers invoking an existing agent. See the official permissions reference. |
| Azure AI User | Backend Container App managed identity | Foundry project | az role assignment create (postprovision hook) |
Backend calls Foundry Hosted Agents at runtime via DefaultAzureCredential |
| Azure AI User | Per-agent Entra identity (agent.instance_identity.principal_id) |
Foundry account | scripts/grant_agent_rbac.py (postdeploy hook) |
Each refreshed-preview hosted agent uses its own dedicated Entra identity to call the Responses API; the azd ai agent extension provisions the identity but does not grant data-plane RBAC |
The first six roles are assigned by infra/modules/role-assignments.bicep during azd provision. The remaining roles — including the deployer's Azure AI Project Manager grant — are assigned via az role assignment create in the postprovision hook. This is intentionally outside Bicep because the CLI command is natively idempotent (no error if the role was previously granted manually).
First-run note: Azure RBAC propagation can take up to ~60 seconds after a new role assignment. The
postdeployhook (scripts/grant_agent_rbac.py) prints a NOTE if it just created any new role assignments — if the very first agent invocation afterazd upreturnsPermissionDenied, wait one minute and retry. On subsequent deploys the roles already exist and no waiting is needed.
Agent registration is performed by azd deploy itself via the
azd ai agent extension. Each agent has an entry under the services:
block in azure.yaml with host: azure.ai.agent and a matching
agents/<name>/agent.yaml that declares the agent's canonical metadata
(name, protocols, cpu/memory, environment variables). On azd deploy the
extension:
- ACR-builds the agent image from
agents/<name>/Dockerfile(no local Docker daemon required — same flow asaz acr build). - Pushes the image to the ACR connection on the Foundry project.
- Calls
client.agents.create_version()with the new image, requested cpu/memory, and theresponses 1.0.0protocol binding. - Provisions the per-agent
instance_identityandblueprintApplication identities and grantsAcrPullon the registry. - Polls until
status == active(typically 2–5 minutes per agent).
A postdeploy hook (scripts/grant_agent_rbac.py) then grants Azure AI User on the Foundry account scope to each agent's instance_identity
— this is the data-plane role required to serve the Responses API and is
not granted by the extension automatically (the gap that produced the
early preview's persistent 424 session_not_ready and PermissionDenied
errors).
Resource specs are declared per agent in agents/<name>/agent.yaml:
| Agent | CPU | Memory |
|---|---|---|
clinical-reviewer-agent |
1 |
2Gi |
coverage-assessment-agent |
1 |
2Gi |
compliance-agent |
0.5 |
1Gi |
synthesis-agent |
1 |
2Gi |
| Agent ID | Module |
|---|---|
compliance-agent |
agents/compliance/main.py |
clinical-reviewer-agent |
agents/clinical/main.py |
coverage-assessment-agent |
agents/coverage/main.py |
synthesis-agent |
agents/synthesis/main.py |