End-to-End Scenarios: Combining Foundry Tools

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

This page targets Azure CLI 2.90.0, Bicep 0.47.16 and the api-versions and SDKs of the pages it reuses, checked on 2026-10-06. Each walk-through links back to the tool pages for details, and reuses their code. All three run on the same foundry-bookshelf resource (keys disabled), with each workload’s own user-assigned managed identity.

RG=rg-bookshelf
LOCATION=eastus2
FOUNDRY=foundry-bookshelf
FOUNDRY_ID=$(az cognitiveservices account show --name "$FOUNDRY" --resource-group "$RG" --query id -o tsv)
STORAGE_ID=$(az storage account show --name stbookshelf --resource-group "$RG" --query id -o tsv)

1. Supplier invoice pipeline

Publishers' PDF invoices land in the invoices container. Each new blob triggers a Container Apps job that extracts the invoice with Document Intelligence, redacts personal data in free-text notes with Language, and posts the result to orders-api.

flowchart LR op[Operator or mail rule] -->|upload PDF| blob[(stbookshelf invoices)] blob -->|BlobCreated| eg[Event Grid system topic] eg --> q[Storage queue invoice-events] q -->|KEDA scale rule| job[Container Apps job invoice-processor] job -->|prebuilt-invoice| di[Document Intelligence] job -->|PII on notes| lang[Language] job -->|POST supplier-invoices| orders[orders-api] job -->|low confidence| review[Review queue]

Provisioning

az identity create --name id-invoice-processor --resource-group "$RG"
MI_PRINCIPAL=$(az identity show --name id-invoice-processor --resource-group "$RG" --query principalId -o tsv)
MI_ID=$(az identity show --name id-invoice-processor --resource-group "$RG" --query id -o tsv)
ACR_ID=$(az acr show --name acrbookshelf --resource-group "$RG" --query id -o tsv)

az role assignment create --assignee-object-id "$MI_PRINCIPAL" --assignee-principal-type ServicePrincipal \
  --role "Cognitive Services User" --scope "$FOUNDRY_ID"
az role assignment create --assignee-object-id "$MI_PRINCIPAL" --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Reader" --scope "$STORAGE_ID"
az role assignment create --assignee-object-id "$MI_PRINCIPAL" --assignee-principal-type ServicePrincipal \
  --role "Storage Queue Data Message Processor" --scope "$STORAGE_ID"
az role assignment create --assignee-object-id "$MI_PRINCIPAL" --assignee-principal-type ServicePrincipal \
  --role "AcrPull" --scope "$ACR_ID"

az storage queue create --account-name stbookshelf --name invoice-events --auth-mode login

az eventgrid system-topic create --name egt-stbookshelf --resource-group "$RG" --location "$LOCATION" \
  --topic-type Microsoft.Storage.StorageAccounts --source "$STORAGE_ID"
az eventgrid system-topic event-subscription create --name invoices-to-queue \
  --resource-group "$RG" --system-topic-name egt-stbookshelf \
  --included-event-types Microsoft.Storage.BlobCreated \
  --subject-begins-with /blobServices/default/containers/invoices/ \
  --endpoint-type storagequeue --endpoint "$STORAGE_ID/queueServices/default/queues/invoice-events"

az containerapp job create --name invoice-processor --resource-group "$RG" --environment cae-bookshelf \
  --trigger-type Event --replica-timeout 600 --min-executions 0 --max-executions 10 \
  --image acrbookshelf.azurecr.io/invoice-processor:1.0.0 \
  --mi-user-assigned "$MI_ID" \
  --registry-server acrbookshelf.azurecr.io --registry-identity "$MI_ID" \
  --scale-rule-name queue --scale-rule-type azure-queue \
  --scale-rule-metadata accountName=stbookshelf queueName=invoice-events queueLength=1 \
  --scale-rule-identity "$MI_ID" \
  --env-vars FOUNDRY_ENDPOINT=https://foundry-bookshelf.cognitiveservices.azure.com/ ORDERS_API=https://orders-api.internal

The job

Document Intelligence fetches a urlSource itself, with no access to the job’s identity, so a private blob URL would fail. The job therefore downloads the invoice with its own identity (the Storage Blob Data Reader role above) and sends the bytes. A field that prebuilt-invoice didn’t find is simply absent from fields, so the job reads every field with .get() and sends the invoice to review when a required field is missing.

import os, requests
from azure.ai.documentintelligence import DocumentIntelligenceClient
from azure.ai.documentintelligence.models import AnalyzeDocumentRequest
from azure.ai.textanalytics import TextAnalyticsClient
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobClient

cred = DefaultAzureCredential()
endpoint = os.environ["FOUNDRY_ENDPOINT"]
di = DocumentIntelligenceClient(endpoint, cred)
language = TextAnalyticsClient(endpoint, cred)

REQUIRED = ("VendorName", "InvoiceId", "InvoiceTotal")

def process(blob_url: str) -> None:
    data = BlobClient.from_blob_url(blob_url, credential=cred).download_blob().readall()
    doc = di.begin_analyze_document(
        "prebuilt-invoice", AnalyzeDocumentRequest(bytes_source=data)
    ).result().documents[0]
    f = doc.fields or {}
    low = [k for k, v in f.items() if v.confidence is not None and v.confidence < 0.8]
    missing = [k for k in REQUIRED if k not in f]
    term = f.get("PaymentTerm")
    notes = term.content if term else ""
    redacted = language.recognize_pii_entities([notes])[0].redacted_text if notes else ""
    vendor, invoice_id, total = (f.get(k) for k in REQUIRED)
    payload = {
        "vendor": vendor.content if vendor else None,
        "invoiceId": invoice_id.content if invoice_id else None,
        "total": total.value_currency.amount if total and total.value_currency else None,
        "notes": redacted,
        "needsReview": bool(low or missing),
        "source": blob_url,
    }
    token = cred.get_token("api://orders-api/.default").token
    requests.post(f"{os.environ['ORDERS_API']}/supplier-invoices", json=payload,
                  headers={"Authorization": f"Bearer {token}"}, timeout=30).raise_for_status()

The same flow in C#, for teams that write their jobs in .NET:

using Azure;
using Azure.AI.DocumentIntelligence;
using Azure.AI.TextAnalytics;
using Azure.Identity;
using Azure.Storage.Blobs;

var cred = new DefaultAzureCredential();
var endpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_ENDPOINT")!);
var di = new DocumentIntelligenceClient(endpoint, cred);
var language = new TextAnalyticsClient(endpoint, cred);

BinaryData bytes = (await new BlobClient(new Uri(blobUrl), cred).DownloadContentAsync()).Value.Content;
Operation<AnalyzeResult> op = await di.AnalyzeDocumentAsync(WaitUntil.Completed, "prebuilt-invoice", bytes);
var fields = op.Value.Documents[0].Fields;
string notes = fields.TryGetValue("PaymentTerm", out var term) ? term.Content : "";
string redacted = notes.Length > 0 ? language.RecognizePiiEntities(notes).Value.RedactedText : "";

Cost and limits

2. Multilingual, safe reviews

Readers write reviews in any language. Before a review is published it is moderated, its language detected, translated into the catalog’s languages, scored for sentiment, and indexed for search.

flowchart LR reader[Reader submits review] --> api[catalog-api] api -->|text analyze and blocklist| cs[Content Safety] cs -->|severity 4 or more| reject[Reject with message] cs -->|ok| ld[Language detection] ld --> tr[Translator NMT to en, es, fr, de] tr --> sent[Sentiment or LLM classifier] sent --> idx[(search-bookshelf reviews index)] sent --> db[(catalog database)]

Provisioning

CATALOG_PRINCIPAL=$(az identity show --name id-catalog-api --resource-group "$RG" --query principalId -o tsv)
SEARCH_ID=$(az search service show --name search-bookshelf --resource-group "$RG" --query id -o tsv)

az role assignment create --assignee-object-id "$CATALOG_PRINCIPAL" --assignee-principal-type ServicePrincipal \
  --role "Cognitive Services User" --scope "$FOUNDRY_ID"
az role assignment create --assignee-object-id "$CATALOG_PRINCIPAL" --assignee-principal-type ServicePrincipal \
  --role "Search Index Data Contributor" --scope "$SEARCH_ID"

The review pipeline

from azure.ai.contentsafety import ContentSafetyClient
from azure.ai.contentsafety.models import AnalyzeTextOptions
from azure.ai.textanalytics import TextAnalyticsClient
from azure.ai.translation.text import TextTranslationClient
from azure.identity import DefaultAzureCredential
from azure.search.documents import SearchClient

cred = DefaultAzureCredential()
ep = "https://foundry-bookshelf.cognitiveservices.azure.com/"
safety, language = ContentSafetyClient(ep, cred), TextAnalyticsClient(ep, cred)
translator = TextTranslationClient(credential=cred, endpoint=ep)
search = SearchClient("https://search-bookshelf.search.windows.net", "reviews", cred)

def publish_review(review_id: str, isbn: str, text: str) -> bool:
    verdict = safety.analyze_text(AnalyzeTextOptions(text=text, blocklist_names=["bookshelf-spoilers"]))
    if verdict.blocklists_match or any(c.severity >= 4 for c in verdict.categories_analysis):
        return False
    lang = language.detect_language([text])[0].primary_language.iso6391_name
    targets = [t for t in ("en", "es", "fr", "de") if t != lang]
    translations = {t.language: t.text for t in translator.translate(body=[text], to_language=targets)[0].translations}
    sentiment = language.analyze_sentiment([text], language=lang)[0].sentiment
    search.upload_documents([{"id": review_id, "isbn": isbn, "language": lang, "text": text,
                              "sentiment": sentiment, **{f"text_{k}": v for k, v in translations.items()}}])
    return True

Sentiment is a Language legacy feature with end of support on 2029-03-31; replace the analyze_sentiment call with an LLM classifier as shown on Language: Sentiment, Summarization, CLU and Question Answering when you next touch this code.

using Azure.AI.ContentSafety;
using Azure.AI.Translation.Text;
using Azure.Identity;

var cred = new DefaultAzureCredential();
var ep = new Uri("https://foundry-bookshelf.cognitiveservices.azure.com/");
var safety = new ContentSafetyClient(ep, cred);
var translator = new TextTranslationClient(cred, ep);

var verdict = safety.AnalyzeText(new AnalyzeTextOptions(reviewText)).Value;
bool ok = verdict.CategoriesAnalysis.All(c => c.Severity < 4);
if (ok)
{
    var spanish = (await translator.TranslateAsync("es", reviewText)).Value[0].Translations[0].Text;
    Console.WriteLine(spanish);
}

Cost and limits

3. Accessible catalog

Every cover uploaded to stbookshelf gets alt text, which is translated into the catalog’s languages, and every book description gets a short audio preview, served through Front Door.

flowchart LR pub[Publisher uploads cover] --> covers[(stbookshelf covers)] covers -->|BlobCreated| job[Container Apps job catalog-a11y] job -->|caption, gender neutral| vis[Vision Image Analysis] job -->|alt text to es, fr, de| tr[Translator] job -->|description to MP3| tts[Text to speech] tts --> media[(stbookshelf media)] media --> afd[Front Door afd-bookshelf] afd --> browser[Catalog page with alt text and audio]

Provisioning

param location string = resourceGroup().location
param foundryName string = 'foundry-bookshelf'
param visionName string = 'vision-bookshelf'
param storageName string = 'stbookshelf'

resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
  name: 'id-catalog-a11y'
  location: location
}

resource foundry 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = {
  name: foundryName
}

resource vision 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = {
  name: visionName
}

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' existing = {
  name: storageName
}

var roles = {
  cognitiveServicesUser: 'a97b65f3-24c7-4388-baec-2e87135dc908'
  blobDataContributor: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'
}

resource foundryRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  scope: foundry
  name: guid(foundry.id, identity.id, roles.cognitiveServicesUser)
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles.cognitiveServicesUser)
    principalId: identity.properties.principalId
    principalType: 'ServicePrincipal'
  }
}

resource visionRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  scope: vision
  name: guid(vision.id, identity.id, roles.cognitiveServicesUser)
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles.cognitiveServicesUser)
    principalId: identity.properties.principalId
    principalType: 'ServicePrincipal'
  }
}

resource blobRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  scope: storage
  name: guid(storage.id, identity.id, roles.blobDataContributor)
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles.blobDataContributor)
    principalId: identity.properties.principalId
    principalType: 'ServicePrincipal'
  }
}

Image Analysis captions are not available in every region, so the caption call goes to the single-service vision-bookshelf resource in East US from Vision: Image Analysis and OCR, and the Bicep above grants id-catalog-a11y the Cognitive Services User role on both resources. If your foundry-bookshelf region supports captions, point the Vision client at foundry-bookshelf instead and drop the visionRole assignment.

The job

import azure.cognitiveservices.speech as speechsdk
from azure.ai.translation.text import TextTranslationClient
from azure.ai.vision.imageanalysis import ImageAnalysisClient
from azure.ai.vision.imageanalysis.models import VisualFeatures
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobClient

cred = DefaultAzureCredential()
vision = ImageAnalysisClient("https://vision-bookshelf.cognitiveservices.azure.com/", cred)
translator = TextTranslationClient(credential=cred, endpoint="https://foundry-bookshelf.cognitiveservices.azure.com/")
speech = speechsdk.SpeechConfig(endpoint="https://foundry-bookshelf.cognitiveservices.azure.com/", token_credential=cred)
speech.speech_synthesis_voice_name = "en-US-AvaMultilingualNeural"
speech.set_speech_synthesis_output_format(speechsdk.SpeechSynthesisOutputFormat.Audio24Khz48KBitRateMonoMp3)

def enrich(isbn: str, cover_url: str, description: str) -> dict:
    caption = vision.analyze_from_url(cover_url, [VisualFeatures.CAPTION], gender_neutral_caption=True).caption.text
    alt = {t.language: t.text for t in translator.translate(body=[caption], to_language=["es", "fr", "de"])[0].translations}
    alt["en"] = caption
    audio = speechsdk.SpeechSynthesizer(speech_config=speech, audio_config=None).speak_text_async(description).get()
    BlobClient(f"https://stbookshelf.blob.core.windows.net/media/previews/{isbn}.mp3", credential=cred) \
        .upload_blob(audio.audio_data, overwrite=True)
    return {"isbn": isbn, "altText": alt, "audioPreview": f"https://afd-bookshelf.example/media/previews/{isbn}.mp3"}
using Azure.AI.Vision.ImageAnalysis;
using Azure.Identity;

var vision = new ImageAnalysisClient(
    new Uri("https://vision-bookshelf.cognitiveservices.azure.com/"), new DefaultAzureCredential());
var caption = vision.Analyze(new Uri(coverUrl), VisualFeatures.Caption,
    new ImageAnalysisOptions { GenderNeutralCaption = true }).Value.Caption.Text;
Console.WriteLine($"alt=\"{caption}\"");

Check the published preview through Front Door:

curl -sI "https://afd-bookshelf.example/media/previews/9780143111597.mp3" | grep -i -E "^(HTTP|content-type|x-cache)"

Cost and limits

  • Per book: one Image Analysis transaction, three short translations, and about 400 characters of neural text to speech. Previews are generated once and cached at the edge, so playback costs only Front Door egress.

  • Image Analysis retires on 2028-09-25; plan to move captioning to a Foundry Model before then.

  • Details: Text to Speech and Avatars and Front Door and CDN.

Clean up

az containerapp job delete --name invoice-processor --resource-group "$RG" --yes
az containerapp job delete --name catalog-a11y --resource-group "$RG" --yes
az eventgrid system-topic delete --name egt-stbookshelf --resource-group "$RG" --yes
az storage queue delete --account-name stbookshelf --name invoice-events --auth-mode login
az identity delete --name id-invoice-processor --resource-group "$RG"
az identity delete --name id-catalog-a11y --resource-group "$RG"