Skip to main content

2 posts tagged with "azure-functions"

View All Tags

From a Local Claude Code QBR to a Copilot-Ready Azure Function

· 7 min read
Mike Homol
Principal Consultant @ ThreeWill

A manufacturing client already had a working Customer QBR generator. It lived in Claude Code: a Python script, a handful of Excel extracts, and a self-contained HTML dashboard that sales could actually present. The ask was not “make Copilot write a nicer report.” It was “let sales ask Copilot for the same dashboard, for any customer, without running a laptop script.”

That sounds like a prompt problem. It was an architecture problem.

The local solution was already good

Someone on the client side had used Claude Code to build a deterministic pipeline, not a chatty summarizer. The script read four monthly Excel workbooks (orders, returns, forecast, item/customer master), computed a pile of KPIs, and injected the results into an HTML template. The output was an interactive canvas: YTD sales, units, on-time delivery, fill rate, forecast variance, RMAs, pipeline, recommended actions.

That is the kind of artifact consulting teams usually wish they had. Charts with real numbers. Tooltips. A predictable layout so the next quarter looks like the last quarter, just with new data.

The Excel files were not a convenience dump. They were the contract. Sheet names looked like ERP/BI view exports. The Python encoded client-specific windows and matching keys on top of that shape. If you skip the contract and ask an LLM to “read the spreadsheet and build a dashboard,” you get a different report every time — and you cannot hand it to a customer.

Phase 1 had to mimic that pipeline, not replace it. Going straight to the source system was the right end state — just not the first proof.

What Copilot cannot fake

The Copilot Studio agent had been pointed at Excel as knowledge and asked to author HTML. Two things break that design:

  1. Generative HTML is not the generator. The Python already owned the KPI math. An LLM rewriting dashboards from spreadsheets will drift, miss rows, and invent layout. Sales QBR is a repeatable artifact, not a one-off essay.
  2. Copilot Studio does not natively hand back a file. Chat can show a summary. It cannot reliably deliver a downloadable, self-contained HTML canvas unless something else produces the bytes and returns a link — Power Automate, an Agent Flow, or an external API.

The gap was wiring. Copilot should collect a customer name and return a URL. The engine should stay Python.

Wrap the engine, keep the contract

I stood up a Python 3.11 Azure Function App as an HTTP wrapper around the original generator. First data mode was Excel: the same four workbooks, staged in Blob (or a local folder for the laptop proof). The Function resolved a customer name, ran the engine, uploaded the HTML, and returned a time-limited SAS download URL.

A ThreeWill-tenant POC was enough to prove the stack before recreating it in the client’s subscription. Function-level auth (code / x-functions-key) kept the first connector simple.

MethodRouteRole
GET/api/healthLiveness + which data mode is live
GET/api/qbr/customersCustomer name search
POST/api/qbrStart generation → jobId
GET/api/qbr/statusPoll until downloadUrl

Copilot Studio imported that surface as a REST API from OpenAPI. Four operations, API key in the query string, no custom connector science project.

Copilot’s 30-second wall

Generation is not a cheap lookup. After warm-up it is often 15–60 seconds; a cold run with a fat extract can take a couple of minutes. Copilot tool calls time out around 30 seconds.

So the default POST /api/qbr does not wait for HTML. It queues a Storage Queue message, returns 202 + jobId immediately, and a queue-triggered worker does the minutes-long work. A sync=true path exists for curl and smoke tests — Copilot never uses it.

Agent instructions matter as much as the API. Copilot is an orchestrator: search if the name is fuzzy, start a job, remember jobId, and on “ready?” call status with that id only. It must not author HTML, invent KPIs, or re-ask for the customer name just to poll. Hiding customerName on the status tool is what stops that re-prompt.

SAS lifetime defaults to 24 hours. The link is a download, not a forever intranet page.

Then stop copying Excel

Once Copilot could deliver the same canvas, the monthly Excel ritual became the next bottleneck. Those workbooks were already pictures of lakehouse views. Phase 2 flipped QBR_DATA_MODE from excel to fabric.

Same Function. Same OpenAPI. Same Copilot tools. Different loaders:

  1. Resolve the customer in the lakehouse (company name → customer key)
  2. Query orders, RMAs, and forecast by that key
  3. Take the latest forecast version
  4. Pull deals from the CRM table and inventory for the customer’s items
  5. Still load the HTML template from Blob — branding is injected at generate time

An Entra app registration in the client’s tenant, granted on the Fabric workspace/lakehouse, lets the Function use Lakehouse SQL. The Function host needs ODBC Driver 18; that is a runtime footnote, not a Copilot change.

Excel mode stayed as rollback. That mattered during the first Fabric mapping: column case, identifier quoting, and “which table actually has city/state” are the unglamorous half of reuse.

What sales actually get

The deliverable is still the interactive HTML — not a chat summary pretending to be a QBR.

Customer QBR dashboard — names, logo, and item codes redacted

Typical sections:

  • Customer summary (YTD sales and units vs prior year)
  • Monthly sales and order-to-ship
  • On-time delivery and fill rate
  • Top items, forecast vs actual, recommended forecast actions
  • Returns / RMA
  • Open pipeline

The layout is stable because the template is stable. Copilot’s job is to get the right customer into the engine and hand back the file. The screenshot above is a real generated dashboard with account names, logos, and item codes swapped for placeholders.

What I would repeat

  • Treat a local Claude Code win as a product to host, not a prompt to recreate. If the Python already computes the truth, wrap it.
  • Make the extract schema explicit before you skip it. Excel was the contract that let us prove Copilot delivery without boiling the ERP ocean.
  • Design for Copilot timeouts on day one. Async job + poll + SAS link is boring and it works.
  • Keep the LLM off the KPI path. Instructions that say “you do not author HTML” are load-bearing.
  • Flip data mode behind the same OpenAPI. Copilot makers should not notice Fabric vs Excel except that the numbers stop depending on last month’s export.

The pattern is reusable anywhere a desktop agent built a real report and the enterprise agent is expected to serve it: host the engine, let Copilot orchestrate, put durable data behind the same API when the first file lands.

Coding with Nate

· 7 min read
Mike Homol
Principal Consultant @ ThreeWill

This is cross-posted here.

Adventures in getting your kids interested in coding

This is a series of posts I hope to do cataloging some of my progress with spreading the gospel of code to my kids. Recently I made some progress with my son, Nate, and hope to share what we build together in this series. 😊

Part One: Idea and Schema

For many years, I've struggling in fits and spurts to get my kids to embrace coding. I took to it like a duck to water and was coding on this when I was 9 years old:

Behold the Radioshack TRS-80
Behold the Radioshack TRS-80

I've had a few glimmers of hope, like when they were in robotics and were helping to write the programs, but eventually that died out. Then I had a brief period where my sons were doing lessons on freecodecamp.org. That site is fantastic, by the way. But so far, nothing lasting. But I press on.

Something we both like

About a month ago, I challenged my sons to come up with an app idea. My thinking was that they might be more inclined to learn something if it would result in something that originated from their imagination. My son Nate started doing the Swift Playground lessons and things were looking up. But as usual, things sputtered out.

2 weekends ago, we were playing our favorite collector card game, Marvel Legendary. I have amassed pretty much the entire collection and we spent a fair bit of the weekend battling supervillains. There have been some apps made by the community already - namely one called Assemble. It randomizing matchups, which is really nice for a game that keeps growing with 3 or 4 expansions a year. But this app was starting to slow down in its updating. Additionally my son voiced his frustration with not being able to search up cards by the text on them or by their features. Suddenly, I saw the light go on and he turned to me and said, "Maybe this is the app we can build."

Eureka! We had a problem that we wanted to solve and it was surrounding a topic that we both enjoyed. It was the perfect storm.

Early Plan

So the basics for the idea are now there. We want a mobile app that will allow for in-depth searching of thousands of cards and that will also randomize setups of games.

Technically, here's the early plan: a database of cards built in Azure Data Storage, accessible from Azure Functions, via a mobile app built in React Native. We will open source the whole thing, so feel free to contribute or give feedback, especially if you happen to also play the game.

Homework

My homework for Nate so far is to start going through the JavaScript lessons on freecodecamp.org. Send me thoughts if you have some better ideas. My thinking is for him to try and pick up some basics of JavaScript while we're building out the backend. While he's learning, he's volunteered to help with data entry. We'll see how long that lasts.

Looking at Cards

Sooo many cards!

Our first order of business was getting the data figured out. My first real teachable moment. We examined various cards and started making notes, first in bullet lists of the various types of cards and the properties we were seeing on them. Here are some examples.

MastermindHeroVillian
Dark Phoenix MastermindDeadpoolShe-Hulk Villain

As you can see, there are many various qualities and this is just a small sample. In fact, just at the top-most level, there are 10 types of cards: Wound, Token, Officer, Sidekick, Bystander, Scheme, Enemy Leader, Enemy Support, Enemy Group, Playable. Pretty quickly in, we switched to a JSON document and started going over samples of each card type, appending properties to the same single JSON as we discovered new things on a card. This actually sunk in with him. Seeing the JSON get updated live as we were reviewing information on a card was like seeing the objects and properties come to life real-time on the screen. Here's what we ended up with:

{
"Name": "",
"StartingDeck": "Hero | Villain | Officer | Sidekick | Wound | Bystander | Mastermind | Token",
"Type": "Wound | Token | Officer | Sidekick | Bystander | Scheme | Enemy Leader | Enemy Support | Enemy Group | Playable",
"Classification": "Wound | Grievous Wound | Bindings | Villain | Officer | Madame Hydra | Hero | Special Sidekick | Bystander | Recruitable Bystander | Scheme | Plot | Mastermind | Commander | Henchmen | Backup Adversaries | Villain | Adversary",
"Team": "Shield | Avengers | X-Men | Champions | Fantastic Four | Heroes of Asgard | Sinister 6 | Hydra | Foes of Asgard | Crime Syndicate | Brotherhood | Cabal | Guardians of the Galaxy | Illuminati | Marvel Knights | Mercs for Money | New Warriors | Spider Friends | Venomverse | Warbound | X-Force",
"Class": "Instinct | Ranged | Strength | Covert | Tech | Basic",
"Color": "Yellow | Blue | Green | Red | Silver | Grey",
"Ability": "",
"Keywords": [
"Healing",
"Betrayal",
"Ambush",
"Human Shield",
"Phasing",
"Fight",
"Escape",
"Overrun",
"Fight or Fail",
"X Uru-Enchanted Weapon"
],
"CombinationSymbols": [],
"CombinationEffect": "",
"Expansion": "Core | Villains | Civil War | X-Men | S.H.I.E.L.D.",
"AlwaysLeads": "",
"CommandMasterStrike": "",
"VictoryPoints": 6,
"Attack": 4,
"Recruit": 2,
"RecruitCost": 3,
"IsRecruitable": true,
"Setup": "",
"Twist": "",
"NumTwists": {
"1": 9,
"2": 9,
"3": 9,
"4": 6,
"5": 6
},
"NumHeros": {
"1": 4,
"2": 5,
"3": 5,
"4": 5,
"5": 6
},
"NumVillains": {
"1": 1,
"2": 2,
"3": 3,
"4": 3,
"5": 4
},
"SchemeWins": "",
"SpecialRules": ""
}

Seeing as we will have to start simple, we will begin by manually creating JSON docs for cards, until we get a UI going. So to try and make it easier on us, especially Nate, I set out to make a schema for this sample doc. This route would give us intellisense and document validation in a good editor, like VS Code.

JSON Schema

This was my first foray into JSON Schema. I started at the source and read through this walkthrough: Getting Started Step-by-step. That gave me most of the tools I needed to understand what was going on. Essentially, you're setting up type definitions and other rules as you identify them.

That said, it was still a pretty big doc for me to try and render out the schema manually, so I started with a schema generator - found here - and applied changes from there, namely titles and descriptions, as well as identifying things we missed as I was validating fields. The generator helps a lot with following along, as you can compare your node to the schema generated. The only big thing I had to learn how to add was the enum property. This is a great option for string properties. It allows you to provide an array of options for editors to leverage. Here's an example:

"enum": ["Yellow", "Blue", "Green", "Red", "Silver", "Grey"]

And here's what happens in VS Code when you reference the schema and attempt to add a color field to the document:

End result in VS Code

Pretty cool, huh? Well I thought so at least lol.

'Til next time

So far, the schema is all we have and we're starting to create the data for Azure Storage. I'll share more as we make progress. Hopefully he stays interested and we actually produce a working product in the App Store. We shall see.

The full schema is published here: https://homol.work/json/legendarydb/card-schema.json