← Back to Blog

Building an Agentic AI POC on Azure AI Foundry: The Cloud-Optimizer-Agent

A same-day proof of concept — an Azure AI Foundry agent with real function/tool calling, wired end-to-end into a live Django backend, including every error message we actually hit: secret ID vs. value, RBAC roles, and why tools live on the agent, not the request.

Building an Agentic AI POC on Azure AI Foundry: The Cloud-Optimizer-Agent

A same-day proof of concept — an Azure AI Foundry agent with real function/tool calling, wired end-to-end into a live Django backend and published on lanacloud.com — including every error message we actually hit along the way.


Why This Project

Most "we built an AI agent" write-ups show you the finished chatbot and skip the part that actually matters to an engineering audience: what happens the first time the model tries to call your tool and the platform says no. This post doesn't skip that part.

The goal of this POC was narrow and deliberate: prove that LanaCloud can stand up an Agentic AI architecture with genuine function/tool calling — not a scripted demo, but a model that decides on its own when it has enough information to call check_architecture_cost, executes that call against a real backend, and turns a numeric result into a professional recommendation. The scenario: a client describes their cloud setup (provider, monthly spend, whether they run a Lakehouse), and the agent estimates their optimization savings and proposes a Delta Lake / Medallion migration.

Architecture diagram: browser to Django backend to Azure AI Foundry agent, with the check_architecture_cost function tool executed locally

Browser → Django view → Azure AI Foundry Responses API (agent_reference) → function_call → local Python execution → function_call_output → final answer.

The full stack: a chat widget on the Agentic AI service page → a Django view → the azure-ai-projects SDK's OpenAI-compatible Responses client → the Cloud-Optimizer-Agent in Azure AI Foundry → a local Python function that computes the actual savings number.


How to Use This Guide

This is written as a build log, not a marketing summary. Each part covers what we configured, what broke, and what the actual fix was — including the exact error text, because that's the part tutorials usually leave out.

Prerequisites

Requirement Notes
Azure subscription with AI Foundry access A resource + project in ai.azure.com
A chat-completion model deployment We used gpt-5-mini (Global Standard)
Microsoft Entra App Registration Server-to-server auth — Foundry's Responses API uses Entra ID, not a plain API key
azure-ai-projects + azure-identity Python SDKs — pip install azure-ai-projects azure-identity
An existing backend to wire it into We used Django, but the pattern is framework-agnostic

Build sequence

Step What we built Hard dependency
1 Foundry project + model deployment Azure subscription with quota
2 Agent + system prompt Project + model deployed
3 Function tool JSON Schema Agent created
4 Tool registered on the agent version (not per-request) SDK access to client.agents
5 Django backend (foundry_agent.py) App Registration + RBAC role
6 Chat widget on the website Backend endpoint working locally
7 Production deploy (Render) All of the above, plus secrets set in the dashboard

Part 1 — Creating the Project and Agent in Azure AI Foundry

In ai.azure.com, we created a project named Lanacloud-Architecture-Audit under the lanacloud-Architecture--resource resource, then deployed gpt-5-mini as a Global Standard deployment. From the Agents tab, we created a new agent named Cloud-Optimizer-Agent.

Azure AI Foundry Playground showing the Cloud-Optimizer-Agent, model gpt-5-mini, instructions panel, and Tools section

The agent's Playground — model, instructions, and tools all live on the agent's own version, not on the calling application.

The Foundry portal also surfaces the exact endpoint and calling convention you need for code — under Details → Endpoints, we found the project endpoint and a dedicated "Responses protocol" URL for this agent, plus a ready-to-paste Python snippet using the azure-ai-projects SDK's OpenAI-compatible client:

from azure.identity import DefaultAzureCredential
from azure.ai.projects import AIProjectClient

endpoint = "https://lanacloud-architecture--resource.services.ai.azure.com/api/projects/lanacloud-Architecture-Audit"

project_client = AIProjectClient(endpoint=endpoint, credential=DefaultAzureCredential())

my_agent, my_version = "Cloud-Optimizer-Agent", "2"
openai_client = project_client.get_openai_client()

response = openai_client.responses.create(
    input=[{"role": "user", "content": "Tell me what you can help with."}],
    extra_body={"agent_reference": {"name": my_agent, "version": my_version, "type": "agent_reference"}},
)
print(response.output_text)

This confirmed the calling convention: it's the OpenAI Responses API, addressed at a specific agent via extra_body={"agent_reference": {...}} — not the older Assistants-style thread/run polling loop.


Part 2 — Writing the System Prompt

The agent's instructions do more than describe a persona — they enforce the exact rule the function-calling architecture depends on: never estimate the numbers yourself, always call the tool.

You are a Cloud & Data Architect expert at Lanacloud. Your role is to analyze the
infrastructure configuration provided by the client (cloud provider, monthly budget,
whether a Lakehouse is in place) and use your `check_architecture_cost` tool to
evaluate opportunities for cost, performance, and security optimization.

Rules:
- As soon as you have the 3 required pieces of information (cloud provider, monthly
  budget, whether a Lakehouse is in place), call the `check_architecture_cost` tool
  before answering. Never calculate the savings yourself.
- If any information is missing, ask a short, precise question to get it before
  calling the tool.
- Once you receive the tool's result, explain it in 3-4 professional sentences: the
  estimated savings amount, an architecture recommendation, and a remark about
  performance or security relevant to the cloud provider mentioned.
- Respond in the language used by the client (French or English).
- Stay factual: don't promise figures beyond what the tool returns.

That last rule under "Rules" — never calculate the savings yourself — is what turns this from a chatbot with a canned script into a genuine tool-calling agent: the model has to recognize when it has enough structured information and defer the actual math to code it doesn't control.


Part 3 — Defining the Function Tool

The tool is a plain JSON Schema function definition — no remote endpoint, because the function runs locally in our own backend:

{
  "type": "function",
  "name": "check_architecture_cost",
  "description": "Calculates the savings and optimization potential of a Lakehouse or Data Warehouse",
  "parameters": {
    "type": "object",
    "properties": {
      "cloud_provider": { "type": "string" },
      "monthly_spend": { "type": "number" },
      "has_lakehouse": { "type": "boolean" }
    },
    "required": ["cloud_provider", "monthly_spend", "has_lakehouse"]
  }
}

And the local execution — the actual "brain" behind the tool call:

def _check_architecture_cost(cloud_provider: str, monthly_spend: float, has_lakehouse: bool) -> dict:
    savings = monthly_spend * 0.10 if has_lakehouse else monthly_spend * 0.25
    return {
        "estimated_savings_usd": round(savings, 2),
        "recommendation": f"Migrate to a Delta Lake / Medallion architecture on {cloud_provider}",
    }

Part 4 — The Discovery: Tools Belong to the Agent, Not the Request

This is the part every quickstart skips. Our first backend attempt passed the tool schema on every call, the same way you would with a plain OpenAI Chat Completions integration:

response = client.responses.create(
    input=next_input,
    tools=[TOOL_SCHEMA],
    extra_body={"agent_reference": agent_reference},
)

That failed immediately:

BadRequestError: Error code: 400 - {'error': {'code': 'invalid_payload',
'message': "Not allowed when agent is specified.", 'param': 'tools',
'type': 'invalid_request_error', ...}}

When you address a request at a named agent (agent_reference), Foundry treats the agent's own definition as the single source of truth for its tools — you can't bolt extra ones on per-request. The fix is to register the tool on the agent's version itself, using the management surface of the azure-ai-projects SDK (client.agents), not the OpenAI-compatible responses client:

from azure.ai.projects import AIProjectClient
from azure.ai.projects.models import FunctionTool
from azure.identity import ClientSecretCredential

client = AIProjectClient(endpoint=ENDPOINT, credential=ClientSecretCredential(...), allow_preview=True)

current = client.agents.get_version("Cloud-Optimizer-Agent", "2")
definition = current.definition

new_tool = FunctionTool(
    name="check_architecture_cost",
    description="Calculates the savings and optimization potential of a Lakehouse or Data Warehouse",
    parameters={
        "type": "object",
        "properties": {
            "cloud_provider": {"type": "string"},
            "monthly_spend": {"type": "number"},
            "has_lakehouse": {"type": "boolean"},
        },
        "required": ["cloud_provider", "monthly_spend", "has_lakehouse"],
    },
    strict=False,
)

definition.tools = list(definition.tools or []) + [new_tool]
new_version = client.agents.create_version("Cloud-Optimizer-Agent", definition=definition)
print("Created version:", new_version.version)  # -> 3

Running this created version 3 of the agent — the existing web_search tool plus our new check_architecture_cost function, both now part of the agent's own definition. From that point on, the backend only needs to reference {"name": "Cloud-Optimizer-Agent", "version": "3"} — no tools parameter at all.

Azure AI Foundry Tools panel showing check_architecture_cost registered on the agent, with the Edit your function dialog displaying its JSON Schema

After running create_version(), the tool shows up in the portal's own Tools list — proof it now lives on the agent, not in our request payload.


Part 5 — Building the Django Backend

With the tool living on the agent, the backend's job simplifies to: send the user's message, watch for function_call items in the response, execute them locally, and hand the result back as a function_call_output.

def ask(session, user_message: str) -> str:
    client = _openai_client()
    agent_reference = {
        "name": settings.AZURE_AI_FOUNDRY_AGENT_NAME,
        "version": settings.AZURE_AI_FOUNDRY_AGENT_VERSION,
        "type": "agent_reference",
    }
    previous_response_id = session.get("foundry_response_id")
    next_input: list = [{"role": "user", "content": user_message}]

    for _ in range(MAX_TOOL_ROUNDTRIPS):
        create_kwargs = {"input": next_input, "extra_body": {"agent_reference": agent_reference}}
        if previous_response_id:
            create_kwargs["previous_response_id"] = previous_response_id
        response = client.responses.create(**create_kwargs)
        previous_response_id = response.id

        tool_calls = [item for item in response.output if item.type == "function_call"]
        if not tool_calls:
            session["foundry_response_id"] = previous_response_id
            session.modified = True
            return response.output_text

        next_input = []
        for call in tool_calls:
            args = json.loads(call.arguments)
            result = _check_architecture_cost(**args)
            next_input.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": json.dumps(result),
            })

    raise RuntimeError("Max tool-call iterations reached without a final answer")

Two details worth calling out:

  • Session-persisted previous_response_id keeps the conversation stateful across separate HTTP requests, so the agent remembers earlier turns (e.g. "no Lakehouse yet" from a previous message) without us re-sending the full history ourselves.
  • The tool-call loop caps out at MAX_TOOL_ROUNDTRIPS so a misbehaving model can't spin the backend forever — each turn either produces a final answer or another tool call, never both indefinitely.

Part 6 — Wiring It Into the Website

The Django side is a thin, familiar layer: a POST view that calls foundry_agent.ask() and returns JSON, a URL route, and a chat-style widget on the existing Agentic AI service page — reusing the same demo-section styling as our text-to-SQL demo, just with a running chat transcript instead of a single query/response block.

Live Cloud-Optimizer-Agent demo widget on lanacloud.com showing a user message and the agent's cost-savings recommendation

The live demo on lanacloud.com — the agent calls check_architecture_cost, then explains the result in plain language.

A real exchange from testing:

User: We use Azure, $4000/month, no Lakehouse yet.

Cloud-Optimizer-Agent: Estimated savings: $1,000/month.

Recommendation: Migrate to a Delta Lake / Medallion-style lakehouse architecture on Azure (e.g., using Azure Data Lake Storage Gen2 + Delta Lake on Azure Databricks or Azure Synapse Spark) to consolidate ETL, reduce redundant storage and compute, and simplify data governance.

Remark (performance/security): A lakehouse using Delta Lake will improve query performance through data skipping and compaction while enabling stronger transactional guarantees; on Azure, combine this with role-based access via Azure AD, managed identities, and storage encryption (SSE) to maintain data security and meet compliance requirements.

$4,000 × 25% (no Lakehouse) = $1,000 — the number came straight out of our Python function, and the model wrapped it in exactly the professional explanation the system prompt asked for.


Part 7 — The Real Debugging Session

None of the above worked on the first try. In the interest of an honest build log, here's every error we actually hit, in order, with the real fix:

Bug 1 — Secret ID vs. Secret Value

ClientAuthenticationError: Authentication failed: AADSTS7000215: Invalid client
secret provided. Ensure the secret being sent in the request is the client secret
value, not the client secret ID, for a secret added to app '...'.

Azure App Registrations show two columns under Certificates & secrets: a GUID-shaped Secret ID and a long alphanumeric Value. The fix was simply using the Value — but the Value is only ever displayed once, at creation time, so if you've already navigated away, you have to generate a new secret rather than trying to recover the old one.

Bug 2 — Missing RBAC role (403)

PermissionDeniedError: Error code: 403

Authentication succeeding isn't the same as authorization. The App Registration needed the Foundry User role assigned on the Foundry project, granted via Access control (IAM) on the underlying resource in portal.azure.com — not through the Foundry portal's own "Add user" dialog, which searches human directory accounts, not service principals.

Bug 3 — previous_response_id sent as an explicit null

BadRequestError: Error code: 400 - {'error': {'code': 'invalid_payload',
'message': 'Expected string, got Null', 'param': 'previous_response_id', ...}}

On the very first turn of a conversation there is no prior response, but the API expects the parameter to be omitted entirely — not passed as None. The fix was to only add previous_response_id to the request payload when a value actually exists:

create_kwargs = {"input": next_input, "extra_body": {"agent_reference": agent_reference}}
if previous_response_id:
    create_kwargs["previous_response_id"] = previous_response_id
response = client.responses.create(**create_kwargs)

Bug 4 — tools not allowed alongside agent_reference

Covered in full in Part 4 — the fix was registering the tool on the agent's version via client.agents.create_version() instead of passing it per-request.

Four bugs, four different layers of the stack (credential format, authorization, request-payload semantics, and API architecture) — which is a fairly representative sample of what actually happens the first time you wire a new agent platform into a real backend.


What This Build Demonstrates

Capability What Was Demonstrated
Agent configuration Project, model deployment, and agent creation in Azure AI Foundry
Function/tool calling A genuine model-initiated tool call, resolved by real local Python code
Agent versioning Adding a tool via the SDK's agents.create_version(), producing a new immutable version
Backend integration A stateful, session-persisted conversation loop against the Responses API
Production auth Entra ID service principal + RBAC role assignment for server-to-server calls
Live deployment Wired into a Django view, chat widget, and shipped to Render

The point of a POC like this isn't just a working demo — it's the map of exactly where a platform's abstractions leak, so the next integration goes faster. Every error above is now a five-minute fix instead of an afternoon of guessing.

LanaCloud Assistant
Online · usually replies instantly