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)
Source: Create a Foundry resource
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.
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
-
Document Intelligence bills per page for the invoice model; a typical 2-page invoice is two pages. Free-text notes are short, so Language costs one text record per invoice.
-
The default 15 TPS analyze limit is far above Bookshelf’s volume; KEDA’s
max-executions 10keeps bursts under it. -
Details: Document Intelligence: Read, Layout and Prebuilt Models and Language: PII, Entities, Language Detection and Health.
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.
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
Source: Content Safety text quickstart
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
-
Per review: one Content Safety text record, one language detection record, three translations billed per character, one sentiment record. At an average of 600 characters, a review costs a few thousandths of a dollar in total at S0 rates; check the pricing pages for current figures.
-
Content Safety allows 1,000 requests per 10 seconds; Translator allows 50,000 characters per request.
-
Details: Content Safety, Prompt Shields and Groundedness, Text Translation with Translator and Azure AI Search with Foundry Tools.
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.
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'
}
}
Source: Assign Azure roles using Bicep
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"}
Source: How to synthesize speech
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)"
Source: Caching with Azure Front Door
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"
Related pages
-
Resources, Endpoints and Authentication covers the keyless Foundry resource all three scenarios use.
-
Container Apps: Scaling, Jobs and Dapr covers event-driven jobs and KEDA scale rules.
-
Messaging and Integration Services covers Event Grid and Storage queues.
-
Using Foundry Tools from Agents (MCP and Toolbox) shows the same tools combined by an agent instead of a fixed pipeline.