Agentic Coding with Azure Cosmos DB Agent Kit
I let a coding agent build an Azure Cosmos DB app and then asked the Azure Cosmos DB Agent Kit to review it. In this post, I will show you its findings and how many RUs they really saved.
Agentic Coding without Azure Cosmos DB Agent Kit
I wanted to test the Azure Cosmos DB Agent Kit (1) and see what difference it would make for our coding agents. In order to test it properly I turned off the Microsoft Learn and Azure MCP Servers. The source code for the demo is available at GitHub: benjaminbork/azure-cosmos-db-agent-kit-demo.
The application is straightforward: paste a link to a blog article, transform the blog article to a listenable format, and then make an audio version out of it with text-to-speech using Microsoft Foundry. The whole project was done by Claude Opus 5.5 with high effort and the following prompt:
# Build: "Audio Blog" – turn any article URL into an MP3
## Goal
A small web app where I paste an article URL, click "Transform", and get back
a listenable MP3 of that article, rewritten so it sounds natural when spoken.
## Tech stack (fixed)
- Backend: .NET (latest LTS), ASP.NET Core minimal API + a separate background worker service
- Frontend: React + Vite + TypeScript, Material UI, kept simple
- Database: Azure Cosmos DB for NoSQL – the ONLY database
- AI: Azure OpenAI (text rewrite), Azure AI Speech (neural TTS)
- File storage: Azure Blob Storage for the MP3 files
- Hosting: API + worker on Azure Container Apps, frontend on Azure Static Web Apps
- Auth to Azure services: managed identity / DefaultAzureCredential, no keys in code
- Infrastructure as code: Bicep
## User flow
1. User pastes a URL and clicks "Transform"
2. A card appears immediately showing the URL and a progress state
3. The card updates through these states: "Reading article…" → "Generating audio…" → "Ready"
4. Once the text is ready, show a preview of the spoken text
5. When done, show an audio player + download button
6. On failure, show a human-readable reason (e.g. "Page needs JavaScript",
"Article is behind a paywall") and a Retry button
7. A list of all previous articles with their status, newest first
## Processing pipeline
1. Fetch the page HTML in our own code (the AI must NOT browse the URL itself)
2. Extract the main article text using SmartReader (NuGet)
3. Rewrite the text with Azure OpenAI into plain spoken prose: no tables, lists,
code or image references – describe them in sentences instead
4. Convert the text to speech with Azure AI Speech
5. Store the MP3 in Blob Storage, keep the blob private, serve it to the browser via short-lived SAS
## Reliability requirements (important)
- The API must respond instantly; all processing happens asynchronously in the worker
- Saving the article and scheduling its processing must never get out of sync:
no article stuck forever without being processed, no processing without an article
- The worker may crash at any point; after restart, work must continue where it left off
- Processing an article twice must never produce duplicate audio or corrupt data
- Transient failures (timeouts, 429, 5xx) are retried automatically, max 3 attempts;
permanent failures (404, paywall, empty page) fail immediately
- One broken article must never block processing of other articles
- Store which step failed and why, so the UI can show it and Retry resumes from that step
- Re-generating audio with a different voice must not require fetching the page again
## Cost
- Hobby project, single user, should cost only a few euros per month when idle
## Deliverables
- Working solution: API, worker, frontend, Bicep
- Runs locally (document emulators / local setup)
- README explaining: the Cosmos DB data model (containers, item shapes, keys)
and WHY you designed it that way, plus how each reliability requirement is met
The whole app was implemented by the coding agent to a working state. Right out of the box, the agent used a reasonable partition key for the one container. And it made good use of the Azure Cosmos DB change feed to process three steps asynchronously in one change feed worker: extracting the blog HTML content, then rewriting the content and finally creating an audio format out of it with text-to-speech. The extraction and rewriting steps even used transactional batches to ensure that the documents they produced in between and the article update happened together or failed together.
Agentic Coding with Azure Cosmos DB Agent Kit
So all in all the agent did a pretty good job, but let’s see what the Azure Cosmos DB Agent Kit can do for us. After installing it (2) I asked the coding agent for suggestions to improve our Azure Cosmos DB implementations.
Top 4 Findings by impact
| # | Finding | Suggestion | Reason |
|---|---|---|---|
| 1 | The list query probably isn’t using its composite index | Change ORDER BY c.createdAt DESC to ORDER BY c.userId ASC, c.type ASC, c.createdAt DESC. The order of the results stays the same, because both fields have a single value after the filter. | Cosmos DB only uses a composite index for a filter-plus-sort query when the filtered fields come first in the ORDER BY. Without that, it falls back to the createdAt index and filters afterwards. This is the busiest query: the UI polls it every 2 s while an article is processing, and its cost grows with the number of articles. |
| 2 | No Cosmos DB diagnostics or RU logging | Set CosmosClientTelemetryOptions with CosmosThresholdOptions, turn on distributed tracing or OpenTelemetry, and keep the inner exception or diagnostics on ConcurrencyException and the batch failure. | Without diagnostics you can’t tell whether a slow request came from throttling (429s), retries, cross-partition fan-out or network latency. You also need the numbers to check the RU change from #1. |
| 3 | Background queries span all partitions around the clock | Replace CountPendingAsync with SELECT TOP 1 VALUE 1 … and stop after the first page (or skip the database and use the dispatcher’s own state). In QueryDueAsync, set MaxItemCount = maxItems and stop the loop from returning more than maxItems rows. | A cross-partition query costs at least one request per physical partition, and on serverless you pay for every request. The keep-alive only needs a yes/no answer, not a count. The cost is small today but grows as the data spreads over more partitions. |
| 4 | No schema version on the documents | Add "schemaVersion": 1 to the article, source and script items. | ArticleDocument has already changed shape several times (Steps, Progress, Lease, Failure). Documents stay as they were written, and older workers can still be running during a deployment. A version field lets you migrate documents gradually or on read, instead of guessing from which fields are missing. |
The list query probably isn’t using its composite index
Let’s start by analysing the first finding. It was an index recommendation, and in order to evaluate its impact I tested how the RUs changed before and after applying the new index.
public async Task<ArticlePage> ListArticlesAsync(string userId, int pageSize, string? continuationToken, CancellationToken ct)
{
+ // userId and type are constant after the filter, so leading the ORDER BY with them doesn't change the
+ // order. It lets the query use the composite index (userId ASC, type ASC, createdAt DESC) instead of
+ // the single-field createdAt index plus a filter.
var query = new QueryDefinition(
- "SELECT * FROM c WHERE c.userId = @userId AND c.type = @type ORDER BY c.createdAt DESC")
+ "SELECT * FROM c WHERE c.userId = @userId AND c.type = @type ORDER BY c.userId ASC, c.type ASC, c.createdAt DESC")
.WithParameter("@userId", userId)
.WithParameter("@type", ArticleDocument.ItemType);
using var iterator = Articles.GetItemQueryIterator<ArticleDocument>(query, continuationToken, new QueryRequestOptions
{
PartitionKey = UserPrefix(userId),
MaxItemCount = pageSize,
});
var page = await iterator.ReadNextAsync(ct);
+ Track("list articles", isPoint: false, page.RequestCharge, page.Diagnostics);
return new ArticlePage(page.Resource.ToList(), page.ContinuationToken);
}
First page (≤ 100 items), RU before → after (change). Article counts are totals, and the cell shows how many of them the listed user has. 1 partition hit everywhere, same result ✓ everywhere.
| Partitions | Users | 1,000 articles | 10,000 articles | 100,000 articles |
|---|---|---|---|---|
| 1 | 1 | 7.60 → 7.77 (+2 %) · 1,000 per user | 7.62 → 7.80 (+2 %) · 10,000 per user | 7.62 → 7.78 (+2 %) · 100,000 per user |
| 1 | 1,000 | 2.84 → 2.85 (0 %) · 1 per user | 3.25 → 3.29 (+1 %) · 10 per user | 7.37 → 7.78 (+6 %) · 100 per user |
| 5 | 1 | 7.60 → 7.77 (+2 %) · 1,000 per user | 7.59 → 7.95 (+5 %) · 10,000 per user | 7.61 → 7.79 (+2 %) · 100,000 per user |
| 5 | 1,000 | 2.94 → 2.85 (−3 %) · 1 per user | 3.35 → 3.30 (−1 %) · 10 per user | 7.46 → 7.87 (+5 %) · 100 per user |
| 10 | 1 | 7.60 → 7.77 (+2 %) · 1,000 per user | 7.60 → 7.90 (+4 %) · 10,000 per user | 7.70 → 7.79 (+1 %) · 100,000 per user |
| 10 | 1,000 | 2.84 → 2.85 (0 %) · 1 per user | 3.25 → 3.29 (+1 %) · 10 per user | 7.46 → 7.87 (+5 %) · 100 per user |
As you can see, the first index recommendation changed nothing for our workload. Still, it’s nice to get index recommendations, and if we would have a different workload, then things could look different. So I think a good first takeaway is to test index and query tuning recommendations against your workload scenario and see whether they reduce RUs.
No Cosmos DB diagnostics or RU logging
That recommendation I can just totally agree with. It’s critical to monitor your queries to see whether some queries, indexes or partition keys need your attention.
+private void Track(string operation, bool isPoint, double requestCharge, CosmosDiagnostics? diagnostics)
+{
+ if (diagnostics is null)
+ return;
+ var options = containers.Options;
+ var elapsed = diagnostics.GetClientElapsedTime();
+ var threshold = isPoint ? options.SlowPointOperationThreshold : options.SlowQueryThreshold;
+ if (elapsed > threshold || requestCharge > options.HighRequestChargeThreshold)
+ logger.LogWarning("Cosmos DB {Operation} took {ElapsedMs:F0} ms and {RequestCharge:F2} RU: {Diagnostics}",
+ operation, elapsed.TotalMilliseconds, requestCharge, diagnostics);
+ else
+ logger.LogDebug("Cosmos DB {Operation}: {ElapsedMs:F0} ms, {RequestCharge:F2} RU",
+ operation, elapsed.TotalMilliseconds, requestCharge);
+}
The coding agent decided to implement it in the form of application logging. To make it scalable, you need to get your application logs into Log Analytics to see them in production, but the agent never finished the complete setup. I think a nice alternative would have been to enable it through diagnostic settings directly at the Azure Cosmos DB account level. Nevertheless, at least the agent was able to create local logging for now, even though it wasn’t production ready.
Background queries span all partitions around the clock
Now we come to the first query tuning recommendation. It’s a critical recommendation because this query is a cross-partition query. This means it can run over all our data. A logical partition (defined by our partition key) is always in one physical partition. As you could see with the previous index recommendation, the RU costs were the same even though I increased the data volume from fitting in one physical partition all the way up to 10 physical partitions. The costs stayed the same, but this isn’t the case for cross-partition queries. The query has to pay for each physical partition it hits. With that in mind, let’s see what the agent changed thanks to the Azure Cosmos DB Agent Kit.
-public async Task<int> CountPendingAsync(CancellationToken ct)
+public async Task<bool> HasPendingAsync(CancellationToken ct)
{
- var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.type = @type AND IS_DEFINED(c.dueAt)")
+ // Existence check, not a COUNT: stops at the first pending article instead of counting all of them.
+ var query = new QueryDefinition("SELECT TOP 1 VALUE 1 FROM c WHERE c.type = @type AND IS_DEFINED(c.dueAt)")
.WithParameter("@type", ArticleDocument.ItemType);
- using var iterator = Articles.GetItemQueryIterator<int>(query);
- var total = 0;
+ using var iterator = Articles.GetItemQueryIterator<int>(query, requestOptions: new QueryRequestOptions { MaxItemCount = 1 });
while (iterator.HasMoreResults)
- total += (await iterator.ReadNextAsync(ct)).Sum();
- return total;
+ {
+ var page = await iterator.ReadNextAsync(ct);
+ Track("check pending articles", isPoint: false, page.RequestCharge, page.Diagnostics);
+ if (page.Count > 0)
+ return true;
+ }
+ return false;
}
RU before → after (change, partitions hit before → after). Same for 1k, 10k and 100k articles. Same result ✓ everywhere.
| Partitions | Users | 0 pending | 1 pending | 10 % or all pending |
|---|---|---|---|---|
| 1 | 1 | 2.80 → 2.80 (0 %, 1 → 1) | 2.82 → 2.82 (0 %, 1 → 1) | 3.00 → 2.82 (−6 %, 1 → 1) |
| 1 | 1,000 | 2.80 → 2.79 (0 %, 1 → 1) | 2.82 → 2.82 (0 %, 1 → 1) | 2.99 → 2.82 (−6 %, 1 → 1) |
| 5 | 1 | 14.00 → 13.95 (0 %, 5 → 5) | 14.02 → 11.19 (−20 %, 5 → 4) | 14.19 → 11.19 (−21 %, 5 → 4) |
| 5 | 1,000 | 14.00 → 13.95 (0 %, 5 → 5) | 14.02 → 5.61 (−60 %, 5 → 2) | 14.95 → 2.82 (−81 %, 5 → 1) |
| 10 | 1 | 28.00 → 27.90 (0 %, 10 → 10) | 28.02 → 19.56 (−30 %, 10 → 7) | 28.19 → 19.56 (−31 %, 10 → 7) |
| 10 | 1,000 | 28.00 → 27.90 (0 %, 10 → 10) | 28.02 → 8.40 (−70 %, 10 → 3) | 29.90 → 2.82 (−91 %, 10 → 1) |
Changing it from SELECT VALUE COUNT(1) to SELECT TOP 1 VALUE 1, we could save up to 91% of RU costs for our workload. I would say that’s a great saving. Because the TOP 1 ended the query immediately after finding the first match. This could already happen if we were lucky inside the first physical partition. So in our worst case, we could eliminate reading 9 other physical partitions. Imagine if we would have even more data: the SELECT VALUE COUNT(1) would have been an expensive mistake.
No schema version on the documents
Lastly we got our first data modeling recommendation. The Agent Kit is also very strong in recommending partition keys, but our partition keys were already precise. So the kit recommended something else instead: versioning our documents.
public sealed class ArticleDocument
{
public const string ItemType = "article";
+ /// <summary>Bump when the stored shape changes in a way that needs a migration.</summary>
+ public const int CurrentSchemaVersion = 1;
public required string Id { get; set; }
public required string UserId { get; set; }
public required string ArticleId { get; set; }
public string Type { get; set; } = ItemType;
+ /// <summary>Shape of this stored item. Items written before versioning existed read as 1.</summary>
+ public int SchemaVersion { get; set; } = CurrentSchemaVersion;
public required string Url { get; set; }
public abstract class ContentDocument
{
+ /// <summary>Bump when the stored shape changes in a way that needs a migration.</summary>
+ public const int CurrentSchemaVersion = 1;
+
public required string Id { get; set; }
public required string UserId { get; set; }
public required string ArticleId { get; set; }
public abstract string Type { get; set; }
+ /// <summary>Shape of this stored item. Items written before versioning existed read as 1.</summary>
+ public int SchemaVersion { get; set; } = CurrentSchemaVersion;
public DateTimeOffset CreatedAt { get; set; }
}
This is definitely a recommendation worth implementing because it can help simplify migrating our data or allow backward compatibility for our applications.
Summary
Agentic Coding is here to stay and it’s crucial to give our agents as much guidance as possible. Azure Cosmos DB Agent Kit provides the agents with recommendations for query tuning, index tuning, logging, data modeling and much more. Those provided skills make the agents more powerful, and this can help with switching to weaker models in order to save tokens. It’s important for query and index tuning recommendations to compare RU usage before and after applying a recommended fix.
References
Azure Cosmos DB Agent Kit for AI coding assistants - Microsoft Learn (1)