The Next Generation of Agents with Azure and Microsoft Foundry
Every business relies on a help desk, tackling the same twenty common queries repeatedly: my VPN is dropping, I lost my MFA device, I need access to the Finance share. Does that ring a bell? This repetitive nature makes it the ideal testing ground for an AI agent — not just a bot that chats, but a robust system that pulls from real documentation, knows when it should stay silent, and redirects to human support when necessary.
Today, we’re excited to introduce: HelpDesk Copilot. Imagine a Contoso IT service desk where a Microsoft Foundry agent manages employee inquiries, providing responses that are bolstered by an IT knowledge base, complete with references. And when policy dictates that a human must take over, it automatically creates a ticket that flows seamlessly through Dapr and Azure Service Bus to Table Storage, and presents an Adaptive Card in a Microsoft Teams channel.
This entire system is powered by Azure Container Apps, set up using Terraform, and here’s the best part — it involves zero API keys. Every interaction, from retrieving container images to activating the Foundry agent, utilises Microsoft Entra ID and managed identities. My approach even disables local key authentication entirely for the Foundry account.
Here’s what we’ll be diving into:
- The architecture: three ACA apps, one Foundry Prompt Agent, and a ticket pipeline triggered by events
- Why I opted for a single agent rather than a complex multi-agent setup — and why it was the right decision
- Streaming answers grounded in real documentation, supplemented by Foundry File Search and citations
- The escalation pathway: Dapr pub/sub, a Service Bus topic, ticket IDs that are predictable, and idempotency
- The identity model: five managed identities, no connection strings required
- Terraform insights: the Foundry provider landscape may surprise you
- Monitoring with OpenTelemetry and using Foundry’s cloud evaluation API
So grab a coffee — let’s get started!
Inside one ACA environment, three container apps function together, each with a unique network posture:
- Frontend — Built on React 18 and Vite, served through nginx, featuring external ingress. This serves as the public interface where users can access the chat UI and view live tickets.
- API — A FastAPI application paired with a Dapr sidecar, set for internal ingress only. It manages the conversation flows, streams Server-Sent Events, processes requests, and publishes ticket events.
- Ticket worker — Another FastAPI with a Dapr sidecar and no ingress whatsoever. Its sole purpose is to handle Service Bus messages via Dapr, activated by KEDA when there’s workload waiting.
The frontend’s nginx forwards browser requests to the API using the internal DNS of the ACA environment, ensuring that the API is never publicly accessible. Surrounding this setup are Microsoft Foundry (which includes a Prompt Agent and a File Search vector database), a Service Bus topic, Table Storage for ticket records, and Key Vault that securely holds a single secret. We also have Azure Container Registry and Application Insights feeding into a Log Analytics workspace.
I initially designed the system with plans for an orchestrating agent to connect with various specialist agents. However, as reality would have it, my planned Connected Agents pattern is now outdated, and existing workflow orchestration alternatives are still being refined. Rather than making it overly complicated, I restructured my design around a single Foundry Prompt Agent with three clear functionalities:
- File Search across the IT knowledge base, ensuring that answers are credible and properly referenced
- A local create_ticket functionality, for when issues need escalation
- A local tool to get_ticket_status, allowing for easy lookups
Our guiding principle is simple: search first, reference your source, and only create a ticket when no existing protocol applies — or when the procedure specifically states that a human must intervene (like accessing shared Finance drives or dealing with lost devices).
Here’s the crucial takeaway: a single, well-trained agent with effective tools is far superior than a complex multi-agent network for addressing this type of issue. Having a multi-agent setup is just that — a setup, not an inherent strength. When the orchestration options become stable, we can split this design easily. For now, a single agent is simpler, more cost-effective, and easier to monitor.
Similarly, let’s talk about document retrieval: With only ten markdown documents, Azure AI Search feels excessive. Foundry’s in-built File Search vector database suits our needs perfectly. Once the document pool expands to thousands and the requirement for hybrid or semantic ranking arises, we can upgrade seamlessly.
Consider an employee asking: “My VPN keeps dropping every hour.” Here’s how the process works:
- The frontend sends their message to the
/chatendpoint, with an optionalconversation_id - The API either starts new or continues an existing Foundry conversation and asks for a streamed response
- The agent uses File Search to browse through IT documentation and retrieves relevant content
- Text updates and file citations stream back as Server-Sent Events
- The employee sees their answer being typed out in real time, with sources noted below
Citations aren’t just for show. In IT support contexts, knowing “the answer comes from the official VPN procedure” distinguishes between a reliable assistant and a source of confusion. The frontend avoids duplicate citations by displaying filenames clearly with each answer.
The core of the API revolves around the tool-call loop. When the agent needs a local function, the API runs it and feeds the resulting data back into the same Foundry conversation, allowing the agent to craft the final message for the employee. Essentially, the agent remains the conversation’s author, while the API manages any side effects. Here’s a glance at the loop, drawn from agent_service.py:
while True:
stream = openai.responses.create(
input=pending_input,
conversation=conversation_id,
stream=True,
extra_body={"agent_reference": agent_reference},
)
function_outputs = []
for chunk in stream:
if chunk.type == "response.output_text.delta":
yield {"event": "delta", "data": {"text": chunk.delta}}
elif chunk.type == "response.output_item.done":
item = chunk.item
if item.type == "function_call":
yield {"event": "tool_call", "data": {"name": item.name}}
output = self._execute_tool(item.name, item.arguments, conversation_id)
function_outputs.append({
"type": "function_call_output",
"call_id": item.call_id,
"output": json.dumps(output),
})
if function_outputs:
pending_input = function_outputs
continue # submits tool outputs and lets the agent finish its answer
break
Now, let’s talk about a different request: “I need Finance-share access for an audit.” The knowledge base outlines that restricted Finance access always requires creating a ticket. So, the agent will trigger create_ticket, and here’s where our architecture truly shines:
- The API checks the tool input and generates a deterministic ticket ID
- It sends a
ticket.createdevent via its Dapr sidecar to the Service Bus topic calledticket-events, instantly sending the ticket ID back to the employee - Dapr ensures the event reaches the worker via the
ticket-workersubscription - The worker updates the ticket in Table Storage, retrieves any necessary Teams webhook URL from Key Vault, and submits the payload to a Power Automate HTTP flow
- The IT team receives an Adaptive Card in their Teams channel, bringing a human into the interaction
Importantly, chat acknowledgment doesn’t wait for persistence or Teams delivery, resulting in three key outcomes.
Idempotency throughout the system. The ticket ID isn’t generated randomly; it’s derived from the conversation context:
def compute_ticket_id(conversation_id: str, subject: str) -> str:
"""Deterministic ticket ID from (conversation, subject) so a repeated
create_ticket tool call for the same issue in the same conversation
collapses to the same ID instead of creating a duplicate.
"""
key = f"{conversation_id}:{subject.strip().lower()}"
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]If the system retries the tool request or if the Service Bus delivers the event multiple times (up to 10 times allowed), the worker simply updates the same record rather than creating duplicates. We built idempotency into the ID generation, rather than adding it later.
Transparent eventual consistency. There’s a small window where the ticket row may not be visible while KEDA wakes up the worker. Both the agent’s status tool and the ticket endpoints recognise this “not yet visible” state as normal, and the UI polls every five seconds. We’re being honest about the system’s nature here.
It’s about events, not a queue. Currently, there’s just one subscription, making it behave like a work queue. However, “a ticket was created” is an event — tomorrow, you could integrate it with an ITSM connector, audit log, or analytics pipeline, each receiving its own subscription without needing to adjust the API.
This is the payload that flows from the tool call, through Dapr and Service Bus, into the worker, Table Storage, and finally into the Teams flow:
{
"type": "ticket.created",
"ticket_id": "9a549ad5d5f723d4",
"conversation_id": "conversation-id",
"subject": "Request for access to finance shared drive",
"description": "I need access to the finance shared drive for an audit.",
"category": "shared-drive-access",
"urgency": "high",
"requester_email": "email address removed for privacy reasons",
"status": "New",
"created_at": "2026-07-17T16:30:28.396538+00:00",
"updated_at": "2026-07-17T16:30:28.396538+00:00"
}We’ve also designed the failure mode: If the Teams webhook is down or not set, the worker logs a warning but keeps the ticket record. The persistence occurs first, confirming success or prompting a retry based solely on writing to the table. This way, a Teams outage won’t result in multiple ticket entries.
One aspect I take great pride in is that every interaction validates via Entra ID using DefaultAzureCredential. In the ACA, AZURE_CLIENT_ID picks the user-assigned managed identity for each app; locally, the same code uses az login.
- API identity — Access resources like AcrPull, Foundry agent access, Storage Table Data Reader, Key Vault Secrets User, Service Bus Sender. It invokes the agent, reads tickets, and publishes events.
- Worker identity — Allows AcrPull, Storage Table Data Contributor, Key Vault Secrets User, Service Bus Receiver. This is the only creator of tickets.
- Frontend identity — Just allows AcrPull. It retrieves its image and no more.
- Shared Dapr identity — This identity permits Service Bus Data Owner access, scoped for enabling the Dapr component and the KEDA scaler.
Note the separation between reader and writer: the API cannot change tickets, and only the worker is permitted to write to them. This approach to least privilege isn’t just a theoretical point; it’s enforced through RBAC for each identity. The only secret we handle — the Power Automate webhook URL — is safely stored in Key Vault.
Terraform serves as the single source of truth for all Azure resources, split into four modules: platform, foundry, observability, and ACA. Here are two valuable lessons I learned along the way:
- The apparent resources might not be what you need. When I started, I assumed I would require azapi for the Foundry components. The real surprise lay in the azurerm 4.x version, which does offer azurerm_ai_foundry and azurerm_ai_foundry_project — but these provision the classic, hub-based model, rather than the GA project-based Foundry Agent Service that I needed. The modern model provisions directly on a Cognitive Services account, fully supported by azurerm, needing no azapi:
resource "azurerm_cognitive_account" "this" {
name = "${var.prefix}-${var.environment}-foundry-${var.random_suffix}"
resource_group_name = var.resource_group_name
location = var.location
kind = "AIServices"
sku_name = "S0"
# Ensure the account works as a Foundry resource
# (agents, projects) versus mere Cognitive Services.
custom_subdomain_name = "${var.prefix}-${var.environment}-foundry-${var.random_suffix}"
project_management_enabled = true
# Enforces "no API keys anywhere" at account level: only
# Entra ID auth is accepted, key-based auth is rejected.
local_auth_enabled = false
identity {
type = "SystemAssigned"
}
}However, there was one genuine hurdle: the built-in Foundry Agent Consumer role has enough permissions for accessing the Responses API on an existing thread, but for conversations.create() — which the API calls for every new chat — the agents/write permission is needed as well. I confirmed this one firsthand with a 403 error message. The broader Foundry User role would work, but it also grants access to key listing and the entirety of Cognitive Services. The ideal solution is a small custom role that provides only the necessary permissions needed by the chat runtime.
2. Terraform provisions infrastructure — it does not configure agents.
The vector store, document uploads, and agent versions are intentionally not Terraform resources. We run a seed_knowledge.py bootstrap following terraform apply, authenticated as a principal granted the Foundry Project Manager role. Changes to the agent instructions and knowledge content happen independently from infrastructure updates. Mixing the two can lead to unnecessary re-uploads of your knowledge base when containers are resized. Dapr’s entity management is similarly disabled for this reason: Terraform exclusively manages the topic and subscription.
The API is set up to use Azure Monitor OpenTelemetry, and each Foundry invocation receives a custom agent.invoke span that logs the agent’s name, conversation ID, selected tools, and input/output token counts when these are accessible. Logs and metrics from all three apps funnel into the same Log Analytics workspace. If you’re wondering about the costs of a conversation and the tools it selected — App Insights provides those answers.
I also created an evaluation script to send ten fixed questions through the Foundry cloud evaluation API, scoring factors like intent resolution, coherence, and task compliance. Questions that require file searches assess end-to-end functionality, while locally executed function tools need a different approach for response capture — that’s a limitation to keep in mind before promising automated agent quality assurance to your manager.
If this section seems brief, good — covering agent observability and governance in a live setting deserves its own discussion. This is merely an introduction.
One honesty note: HelpDesk Copilot is production-prepared, not production-complete:
- No browser authentication is implemented yet. While conversation IDs structure the ticket interface, they aren’t an authorization barrier. A proper rollout will add Entra ID signing and server-side authorization before any ticket data is shared.
- All tickets remain as ‘New.’ An actual human hand-off is the real signal for notification, not a simulated ITSM lifecycle. Future subscriptions for the topic will address status updates coming from an ITSM tool.
- Global ticket lookups scan all partitions. This is fine for low volume, but a system with heavy demand may require an index.
I’d rather establish clear boundaries than be deceptive.
You can find the full source — Terraform modules, all three services, the knowledge base, and evaluation scripts — on GitHub: passadis/foundry-ticketing
To get started: execute terraform apply, run seed_knowledge.py, build and send off the three images, and ask the public URL why your VPN keeps dropping. With the scale-to-zero feature on all apps and a compact model deployment, there’s virtually no idle cost — the architecture only incurs expenses when assistance is requested.
The AI component of this solution is only about twenty percent of the overall code. The majority involves the behind-the-scenes engineering that makes an agent ready for deployment: managing identity, ingress boundaries, ensuring idempotent events, and maintaining a transparent process of eventual consistency, alongside Infrastructure as Code (IaC) lifecycle management and telemetry. That balance is the real takeaway — and it’s precisely why Azure Container Apps and Microsoft Foundry are such a great duo: the platform handles the heavy workload, allowing you to focus on the strategic decisions.
Coming up next: we’ll dive deeper into agent.invoke spans — exploring aspects such as tracing, token economics, drift management, and governance for agents in production using Foundry’s control plane and Azure Monitor.
Until then — enjoy building!
Share this content:
Discover more from Qureshi
Subscribe to get the latest posts sent to your email.