Modern Apps

Agentic Workflow for Approvals

🔧

Build it yourself. This project is part of the AI Projects for Cloud Solution Architects portfolio. Full source, code, and the latest updates live in the csa-ai-projects repo on GitHub.

Some decisions shouldn't be fully automated. This guide builds a workflow where an agent handles the routing and policy checks, but a human approves or rejects via a Teams message — and every decision is logged immutably.


What You're Building

A Service Bus-triggered Azure Function kicks off a Foundry agent workflow. The agent checks policy via Foundry IQ, determines the right approver using a function call, sends that person a Teams Adaptive Card via Microsoft Graph API, and then waits. When the human responds (approve/reject), a second Function captures the decision, logs it to Cosmos DB, and sends a confirmation. Uses GPT-5.4-mini for reasoning.


Prerequisites

pip install "openai>=1.30.0" azure-identity azure-functions \
  azure-servicebus azure-cosmos msal python-dotenv

Architecture

Agentic Approval Workflow architecture: Service Bus → Azure Function → Foundry Agent GPT-5.4-mini with Foundry IQ → Microsoft Graph Adaptive Card in Teams → human approver → Azure Function → Cosmos DB decisions


Step-by-Step Build

Step 1 — Register Entra ID app for Graph API

# Create app registration
APP_ID=$(az ad app create \
  --display-name "approval-workflow-app" \
  --query appId -o tsv)

# Add Microsoft Graph permissions (TeamsMessage.Send, Chat.Create)
az ad app permission add \
  --id $APP_ID \
  --api 00000003-0000-0000-c000-000000000000 \
  --api-permissions 75359482-378d-4052-8f01-80520e7db3cd=Role \
                    ebf0f66e-9fb1-49e4-a278-222f76911cf4=Role

# Grant admin consent
az ad app permission admin-consent --id $APP_ID

# Create client secret
SECRET=$(az ad app credential reset --id $APP_ID --query password -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)

Step 2 — Create Cosmos DB audit log container

az cosmosdb sql container create \
  --account-name $COSMOS_ACCOUNT \
  --resource-group $RG \
  --database-name approval-workflow \
  --name decisions \
  --partition-key-path "/request_id"

Step 3 — Approver lookup function

# approver_rules.py
# In production, this would query HR systems or an LDAP directory
APPROVER_RULES = {
    "purchase": {
        "0-1000": "manager@company.com",
        "1001-10000": "director@company.com",
        "10001+": "vp@company.com"
    },
    "access": {
        "standard": "it-manager@company.com",
        "privileged": "security-officer@company.com"
    },
    "exception": {
        "default": "compliance@company.com"
    }
}

def determine_approver(request_type: str, amount: float = 0, access_level: str = "") -> dict:
    """Return approver email and display name for a request."""
    rules = APPROVER_RULES.get(request_type, APPROVER_RULES["exception"])

    if request_type == "purchase":
        if amount <= 1000:
            email = rules["0-1000"]
        elif amount <= 10000:
            email = rules["1001-10000"]
        else:
            email = rules["10001+"]
    elif request_type == "access":
        email = rules.get(access_level, rules.get("standard", "it-manager@company.com"))
    else:
        email = rules.get("default", "compliance@company.com")

    return {
        "approver_email": email,
        "approver_name": email.split("@")[0].replace("-", " ").title(),
        "approver_id": email   # In production: look up Entra Object ID
    }


APPROVER_TOOL_DEF = {
    "name": "determine_approver",
    "description": "Look up who should approve this request based on type and amount.",
    "parameters": {
        "type": "object",
        "properties": {
            "request_type": {
                "type": "string",
                "enum": ["purchase", "access", "exception"],
                "description": "Category of the approval request"
            },
            "amount": {
                "type": "number",
                "description": "Dollar amount for purchase requests"
            },
            "access_level": {
                "type": "string",
                "enum": ["standard", "privileged"],
                "description": "Access sensitivity for access requests"
            }
        },
        "required": ["request_type"]
    }
}

Step 4 — Teams message via Graph API

# graph_client.py
import os
import json
import urllib.request
import msal

def get_graph_token() -> str:
    """Get Microsoft Graph access token using client credentials."""
    app = msal.ConfidentialClientApplication(
        client_id=os.environ["ENTRA_CLIENT_ID"],
        client_credential=os.environ["ENTRA_CLIENT_SECRET"],
        authority=f"https://login.microsoftonline.com/{os.environ['ENTRA_TENANT_ID']}"
    )
    result = app.acquire_token_for_client(
        scopes=["https://graph.microsoft.com/.default"]
    )
    if "access_token" not in result:
        raise RuntimeError(f"Token acquisition failed: {result.get('error_description')}")
    return result["access_token"]


def send_approval_card(
    approver_id: str,
    request_id: str,
    request_summary: str,
    requester_name: str,
    callback_url: str
) -> str:
    """Send an Adaptive Card to the approver in Teams."""
    token = get_graph_token()

    # First: create or find a 1:1 chat with the approver
    chat_payload = json.dumps({
        "chatType": "oneOnOne",
        "members": [
            {
                "@odata.type": "#microsoft.graph.aadUserConversationMember",
                "roles": ["owner"],
                "user@odata.bind": (
                    f"https://graph.microsoft.com/v1.0/users/{approver_id}"
                )
            }
        ]
    }).encode()

    req = urllib.request.Request(
        "https://graph.microsoft.com/v1.0/chats",
        data=chat_payload,
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json"
        }
    )
    with urllib.request.urlopen(req) as resp:
        chat = json.loads(resp.read())
    chat_id = chat["id"]

    # Send Adaptive Card
    card = {
        "type": "AdaptiveCard",
        "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
        "version": "1.4",
        "body": [
            {"type": "TextBlock", "size": "Large", "weight": "Bolder",
             "text": "Approval Required"},
            {"type": "TextBlock", "text": f"**Request ID:** {request_id}"},
            {"type": "TextBlock", "text": f"**From:** {requester_name}"},
            {"type": "TextBlock", "wrap": True,
             "text": f"**Summary:** {request_summary}"}
        ],
        "actions": [
            {
                "type": "Action.Http",
                "title": "Approve",
                "method": "POST",
                "url": callback_url,
                "body": json.dumps({
                    "request_id": request_id,
                    "decision": "approved",
                    "approver_id": approver_id
                }),
                "style": "positive"
            },
            {
                "type": "Action.Http",
                "title": "Reject",
                "method": "POST",
                "url": callback_url,
                "body": json.dumps({
                    "request_id": request_id,
                    "decision": "rejected",
                    "approver_id": approver_id
                }),
                "style": "destructive"
            }
        ]
    }

    message_payload = json.dumps({
        "body": {
            "contentType": "html",
            "content": "<attachment id=\"approval-card\"></attachment>"
        },
        "attachments": [{
            "id": "approval-card",
            "contentType": "application/vnd.microsoft.card.adaptive",
            "content": json.dumps(card)
        }]
    }).encode()

    req2 = urllib.request.Request(
        f"https://graph.microsoft.com/v1.0/chats/{chat_id}/messages",
        data=message_payload,
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json"
        }
    )
    with urllib.request.urlopen(req2) as resp:
        msg = json.loads(resp.read())

    return msg["id"]

Step 5 — Azure Functions workflow

# function_app.py
import azure.functions as func
import json, os, logging, uuid
from datetime import datetime, timezone
from azure.cosmos import CosmosClient
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from openai import AzureOpenAI
from approver_rules import determine_approver, APPROVER_TOOL_DEF
from graph_client import send_approval_card

app = func.FunctionApp()
credential = DefaultAzureCredential()

# One Responses-API client drives the policy agent (Foundry IQ file_search +
# the determine_approver function tool). azure-ai-projects 2.x removed the
# Assistants threads/runs surface; the Responses API replaces it.
client = AzureOpenAI(
    azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
    azure_ad_token_provider=get_bearer_token_provider(
        credential, "https://cognitiveservices.azure.com/.default"),
    api_version="2025-04-01-preview",
)
cosmos = CosmosClient(
    url=os.environ["COSMOS_ENDPOINT"],
    credential=credential
)
decisions = cosmos.get_database_client("approval-workflow").get_container_client("decisions")

CALLBACK_BASE_URL = os.environ["FUNCTION_APP_BASE_URL"]
MODEL = os.environ.get("APPROVAL_MODEL", "gpt-5.4-mini")

# Responses API tools: Foundry IQ grounding via file_search + a flat function tool.
TOOLS = [
    {"type": "file_search", "vector_store_ids": [os.environ["POLICY_VECTOR_STORE_ID"]]},
    {"type": "function", **APPROVER_TOOL_DEF},
]

INSTRUCTIONS = (
    "You are an approval workflow agent. For each request:\n"
    "1. Check the policy documents to determine if the request is in-policy\n"
    "2. If in-policy, call determine_approver() to find the right approver\n"
    "3. Summarize the request in 2 sentences for the approver\n\n"
    "If the request violates policy, return {is_in_policy: false, reason: '...'}"
)


@app.service_bus_queue_trigger(
    arg_name="msg",
    queue_name="approval-requests",
    connection="SERVICE_BUS_CONNECTION_STRING"
)
def process_approval_request(msg: func.ServiceBusMessage):
    payload = json.loads(msg.get_body().decode())
    request_id = payload.get("request_id", str(uuid.uuid4()))
    logging.info(f"Processing approval request: {request_id}")

    # file_search runs server-side; only determine_approver needs a round-trip.
    response = client.responses.create(
        model=MODEL,
        instructions=INSTRUCTIONS,
        input=f"Process this approval request:\n\n{json.dumps(payload, indent=2)}",
        tools=TOOLS,
    )

    approver_info = {}
    for _ in range(8):  # safety cap on function-call rounds
        fn_calls = [it for it in response.output
                    if getattr(it, "type", None) == "function_call"]
        if not fn_calls:
            break
        tool_outputs = []
        for call in fn_calls:
            if call.name == "determine_approver":
                args = json.loads(call.arguments or "{}")
                approver_info = determine_approver(**args)
                result = json.dumps(approver_info)
            else:
                result = json.dumps({"error": f"Unknown function: {call.name}"})
            tool_outputs.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": result,
            })
        response = client.responses.create(
            model=MODEL,
            previous_response_id=response.id,
            input=tool_outputs,
            tools=TOOLS,
        )

    agent_summary = response.output_text

    # Record pending decision
    decisions.upsert_item({
        "id": request_id,
        "request_id": request_id,
        "status": "pending",
        "payload": payload,
        "approver": approver_info,
        "agent_summary": agent_summary,
        "created_at": datetime.now(timezone.utc).isoformat()
    })

    # Send Teams card to approver
    if approver_info.get("approver_id"):
        callback_url = f"{CALLBACK_BASE_URL}/api/handle_approval?code={os.environ['FUNC_KEY']}"
        send_approval_card(
            approver_id=approver_info["approver_id"],
            request_id=request_id,
            request_summary=agent_summary[:300],
            requester_name=payload.get("requester_name", "Unknown"),
            callback_url=callback_url
        )
        logging.info(f"Teams card sent to {approver_info['approver_id']}")


@app.route(route="handle_approval", methods=["POST"])
def handle_approval_response(req: func.HttpRequest) -> func.HttpResponse:
    """Called by Teams Adaptive Card button press."""
    body = req.get_json()
    request_id = body.get("request_id")
    decision = body.get("decision")
    approver_id = body.get("approver_id")

    if not all([request_id, decision, approver_id]):
        return func.HttpResponse("Missing fields", status_code=400)

    # Update decision record
    item = decisions.read_item(item=request_id, partition_key=request_id)
    item.update({
        "status": decision,
        "decided_at": datetime.now(timezone.utc).isoformat(),
        "decided_by": approver_id
    })
    decisions.upsert_item(item)
    logging.info(f"Decision recorded: {request_id} → {decision}")

    return func.HttpResponse(
        json.dumps({"status": "recorded", "decision": decision}),
        status_code=200,
        mimetype="application/json"
    )

Test It

# Send a test approval request to Service Bus
import json
from azure.servicebus import ServiceBusClient, ServiceBusMessage

sb = ServiceBusClient.from_connection_string(os.environ["SERVICE_BUS_CONNECTION_STRING"])
with sb:
    sender = sb.get_queue_sender("approval-requests")
    with sender:
        msg = {
            "request_id": "REQ-2024-0042",
            "request_type": "purchase",
            "amount": 3500.00,
            "requester_name": "Alex Johnson",
            "requester_email": "alex.johnson@company.com",
            "description": "Ergonomic desk chair for home office setup",
            "business_justification": "Current chair causing back issues affecting productivity"
        }
        sender.send_messages(ServiceBusMessage(json.dumps(msg)))
        print("Test request sent")

Check Cosmos DB for the pending decision, then simulate the Teams approval:

curl -X POST "http://localhost:7071/api/handle_approval" \
  -H "Content-Type: application/json" \
  -d '{"request_id":"REQ-2024-0042","decision":"approved","approver_id":"director@company.com"}'

Common Mistakes


Extend It

  1. Escalation timer: If no decision within 48 hours, an Azure Function timer escalates the request to the approver's manager automatically.
  2. Approval chain: For high-value requests (>$50K), require two sequential approvals. Store the approval chain in Cosmos DB as an array and gate each step.
  3. Power Apps UI: Replace the Teams card with a Power Apps canvas app that shows all pending approvals in a dashboard, with bulk-approve capability for managers.

Resources