HelpDesk Copilot is a realistic, production-shaped IT service desk for a fictional company, Contoso. An employee describes an IT problem in a browser chat; a Microsoft Foundry agent searches curated IT documentation for a grounded, cited answer, or — when the issue genuinely needs a human — creates a ticket that flows asynchronously to a worker, gets persisted, and shows up as a real Adaptive Card in a Microsoft Teams channel. Every piece runs on Azure Container Apps, provisioned entirely by Terraform, with zero API keys: every service-to-service call authenticates with a user-assigned managed identity.
This isn't a chatbot demo wired to a single script. It's built, deployed, and debugged against real Azure infrastructure — including the RBAC gap and the reverse-proxy quirk documented in Troubleshooting below, both only found by actually driving traffic through the live deployment.
- 🔍 Grounded, cited answers — one Foundry Prompt Agent with a File Search tool over ten synthetic Contoso IT docs. No hallucinated policy; every self-service answer names its source file.
- 🎫 Real escalation, not a toy — the agent decides when a documented procedure requires a ticket (e.g., restricted-data access, lost devices) and calls a validated, idempotent
create_ticketfunction tool. - 📨 A real human sees it — no simulated ITSM status-cycling. The ticket worker posts a real Adaptive Card to a Microsoft Teams channel via a Power Automate flow.
- 🔑 Zero API keys anywhere — Foundry, Key Vault, Service Bus, Storage, and ACR are all reached through
DefaultAzureCredentialand per-service user-assigned managed identities. The only unavoidable secret (a Teams webhook URL) lives in Key Vault, not in code or env files. - 🪫 Least privilege, verified live — the built-in
Foundry Agent Consumerrole turned out not to coverconversations.create(); rather than over-grantFoundry User, this solution defines a custom Foundry role with exactly the three data actions the API actually calls. - ⚡ Dapr-native, no connection strings — the API publishes and the worker subscribes to
ticket-eventsthrough an environment-level Dapr pub/sub component. Local dev without a sidecar falls back to the Service Bus SDK automatically. - 📉 Scale-to-zero by design — frontend and API scale 0→3 on HTTP load; the worker scales 0→5 on a KEDA Service Bus queue-depth rule. Idle cost approaches zero.
- 🏗️ Terraform only, no clickops — every resource, role assignment, and Dapr component definition is in
infra/.azurermwhere it's covered,azapi/custom role definitions where it isn't (and it's commented explaining why).
Frontend, API, and ticket-worker are three Container Apps in one ACA environment. The frontend is the only one with external ingress; the API is internal-only; the worker has no ingress at all — it's woken purely by Service Bus backlog via KEDA. See docs/architecture.md for the full ADR-style write-up (what's actually GA in Foundry Agent Service today, why one Prompt Agent instead of Connected Agents/Workflows, provider-coverage decisions) and solution/solution-overview.md for a deep, sequence-diagram-level walkthrough of every flow.
- An Azure subscription
- Terraform ≥ 1.9
- Azure CLI, logged in (
az login) withaz account set --subscription <id> - Python 3.12 (for
scripts/seed_knowledge.pyandscripts/evaluate_agent.py) - No local Docker required — images are built in the cloud with
az acr build
cd infra
cp terraform.tfvars.example terraform.tfvars
# edit terraform.tfvars: deployer_principal_id (az ad signed-in-user show --query id -o tsv),
# location, and optionally teams_webhook_url once you have a Power Automate flow (see below)
terraform init
terraform plan -out=tfplan.out
terraform apply tfplan.outThis provisions the resource group, ACR, Key Vault, Service Bus, Storage, the Foundry account/project/model deployment, the custom least-privilege Foundry role, and Log Analytics/App Insights — but not the three Container Apps yet, since they need images to exist first.
REGISTRY=$(terraform output -raw container_registry_login_server)
az acr build --registry "$REGISTRY" --image api:latest ../src/api
az acr build --registry "$REGISTRY" --image ticket-worker:latest ../src/ticket-worker
az acr build --registry "$REGISTRY" --image frontend:latest ../src/frontendUncomment/apply the aca module in infra/main.tf (present in this repo) and re-run:
terraform applycd ..
python -m venv .venv && source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -r src/api/requirements.txt python-dotenv
PROJECT_ENDPOINT=$(terraform -chdir=infra output -raw foundry_project_endpoint) python scripts/seed_knowledge.pyThis uploads data/it-docs/*.md into a File Search vector store and registers the helpdesk-agent Prompt Agent version with the File Search tool plus the create_ticket/get_ticket_status function tools.
terraform -chdir=infra output frontend_urlThe ticket-worker posts a plain JSON ticket payload to a Power Automate HTTP-triggered flow, which renders the Adaptive Card — so restyling the card is a flow edit, never a redeploy. See docs/architecture.md §7 for the exact trigger schema and card template to paste in. Without it configured, ticket creation and persistence still work end-to-end; the worker just logs a warning and skips the notification.
- Ask a covered question — "My VPN keeps dropping every hour" — and show the streamed, grounded answer with its source-document citation.
- Ask something that needs escalation — "I need access to the finance shared drive for an audit" — and show the agent calling
create_ticketand returning a ticket ID mid-conversation. - Point at the ticket side panel: the new ticket appears within its next 5-second poll, status
New. - Switch to Microsoft Teams and show the Adaptive Card landing in the channel in real time.
- Open Application Insights: show the
agent.invokecustom span with conversation ID, selected tools, and token counts for both requests.
| Endpoint | Purpose |
|---|---|
POST /chat |
Starts or continues a Foundry conversation; streams SSE events (conversation, delta, tool_call, citation, error, done). |
GET /tickets?conversation_id=... |
Lists tickets in a conversation, used by the UI's polling ticket panel. |
GET /tickets/{ticket_id} |
Looks up one ticket by ID. |
GET /healthz |
Liveness/readiness probe target. |
GET /dapr/subscribe / POST /events/ticket-created |
Dapr pub/sub contract between the API and the ticket worker. |
Key infra/terraform.tfvars values:
| Variable | Purpose |
|---|---|
prefix, environment, location |
Naming and region for every resource. |
deployer_principal_id |
Your Entra object ID — granted Foundry Project Manager so you can run seed_knowledge.py. |
teams_webhook_url |
Optional Power Automate trigger URL; left null stores a harmless placeholder. |
foundry_model_name / _version / _sku_name / _capacity |
Model deployment; GlobalStandard bills per-token, so capacity only sets a throttling ceiling, not idle cost. |
Application configuration is entirely environment variables set by Terraform in the ACA module — see src/api/.env.example and src/ticket-worker for the equivalents used in local development.
Every Azure call uses DefaultAzureCredential. In ACA, AZURE_CLIENT_ID pins each app to its own user-assigned managed identity:
| Identity | Access | Notes |
|---|---|---|
| API UAMI | AcrPull, custom Foundry agent-runtime role, Storage Table Data Reader, Key Vault Secrets User, Service Bus Sender |
The custom role grants exactly endpoints/interact/action + agents/read + agents/write — the built-in Foundry Agent Consumer role is missing the last one, which conversations.create() needs. |
| Worker UAMI | AcrPull, Storage Table Data Contributor, Key Vault Secrets User, Service Bus Receiver |
Sole writer of the tickets table. |
| Frontend UAMI | AcrPull only |
Serves static assets and proxies to the API via the Dapr sidecar — never touches Azure data planes directly. |
| Shared Dapr UAMI | Service Bus Data Owner | Scoped narrowly to the Dapr pub/sub component and the KEDA scaler, since those need one shared identity across the Sender-only/Receiver-only app identities. |
The Foundry account has local_auth_enabled = false — key-based auth is rejected outright. The only unavoidable secret, the Teams webhook URL, lives in Key Vault.
Scope note: the app does not implement browser authentication or employee identity binding. Conversation IDs partition the UI's ticket list but aren't an authorization boundary — add real auth before exposing this beyond a demo.
- ACA diagnostic settings stream platform logs/metrics to a 30-day Log Analytics workspace.
- The API initializes Azure Monitor OpenTelemetry; each Foundry call gets a custom
agent.invokespan with conversation ID, selected tools, and token counts (when the SDK exposes them). scripts/evaluate_agent.pyruns ten fixed questions through Foundry's cloud evaluation API, scoring intent resolution, coherence, and task adherence — small and deliberately not gold-plated into a CI gate.
| App | Range | Trigger |
|---|---|---|
| Frontend | 0 → 3 | HTTP requests |
| API | 0 → 3 | HTTP requests |
| Ticket worker | 0 → 5 | KEDA Azure Service Bus rule, target 5 messages on the ticket-worker subscription |
| Resource | SKU / tier | Approx. monthly idle cost |
|---|---|---|
| Container Apps (×3) | Consumption workload profile | ~$0 (scale to zero) |
| Foundry model deployment | gpt-4o-mini, GlobalStandard |
Pay-per-token only, no idle cost |
| Service Bus | Standard namespace | ~$10 base |
| Storage (Table) | Standard LRS | Pennies at this volume |
| Key Vault | Standard | Pennies (per-operation) |
| Log Analytics + App Insights | Pay-as-you-go, 30-day retention | A few dollars at low log volume |
| Container Registry | Basic | ~$5 |
Total idle cost is roughly the Service Bus namespace plus ACR — a few dollars a month. Traffic-driven costs (model tokens, ACA vCPU-seconds) scale with actual usage. Run terraform destroy when you're done to zero it out entirely.
Two real issues were found by driving traffic through the live deployment, not by reading docs — worth knowing if you hit them too:
426 Upgrade Requiredfrom the frontend to the API. nginx'sproxy_passdefaults to HTTP/1.0, and ACA's internal ingress requires HTTP/1.1+. Rather than chaseproxy_http_version/SNI/FQDN issues through nginx, this solution proxies through the frontend's own Dapr sidecar instead (http://localhost:3500/v1.0/invoke/api/method/...) — same-environment service invocation, no FQDN or TLS plumbing needed at all.403 PermissionDeniedonconversations.create()even with the built-inFoundry Agent Consumerrole assigned. That role only grantsendpoints/interact/action; creating a conversation needsagents/writetoo. Fixed with the custom role described in Security & identity above, rather than over-grantingFoundry User.
cd infra
terraform destroyMIT — see LICENSE. All sample data (Contoso IT docs, tickets) is synthetic.
Made with ❤️ by Konstantinos Passadis


