In 2016, during my MBA at Cal State Long Beach, I competed in a semester-long business simulation competition. Five teams running fictional companies, making quarterly decisions on pricing, production, marketing, and R&D. I was the CIO for ours.
When I downloaded the year 3 quarter 4 reports, I found the first of two questions that defined the course for me. What do we do next?
Up until that point our team had focused on getting a feel for the business, discovering how the pricing and marketing levers worked, submitting our individual decisions, and hoping for the best. It wasn’t working. Our market share dropped to 16%. We had 200 units extra inventory. Our product was a flop. We couldn’t outprice the competition. I was stuck trying to divine some pattern in the data, looking for that last bit of information that would clarify what was happening in our business, what we needed to do.
Analysis paralysis. As a math major, this technique had worked well in the platonic world of absolute truths, and even when I branched out into statistics, I could make sense of seemingly random information by unmasking trends amid the noise. My MBA classes reinforced this view, with cases that allowed us to take an impersonal, outsider’s view of the situation. It was easy to declare a strategy broken and suggest solutions that would require a radical shift in direction. We never had to make binding decisions that required us to live with and interpret the unclear responses.
Staring at the reports with concrete numbers in the past and an unwritten future ahead, I realized an implicit assumption underlying my work until this point: I was using static tools in a dynamic world, and our decisions had more sway on most immediate financial results for our firm. Analysis could illuminate the external world, but it couldn’t make our decisions. This both clarified our job in the competitive environment, and begged the need for a strategic framework to make decisions. Quantitative tools became a method for predicting factors none of us could control, like macroeconomic demand, so we could get a sense for what goals would be realistic, but our job as managers was to create a definition of success for our firm, and a path to get to that point.
This led us to shift our management philosophy. I gathered information on relevant uncontrollable external factors, including estimating the production capacities of our competitors, forecasting the amount of industry sales in the upcoming quarters, and tracking when we’d achieve new model numbers.
Competitor production capacity tracker.Industry demand forecast.R&D and training investment tracker.
This information allowed us to set measurable targets with a clear sense of the actions to get there. For instance, we could set a goal of achieving a 1.5% increase in market share, and with the forecast, would understand the required excess production, financing for overtime, and amount of marketing to increase demand. Our next product was a breakout success, and we had a clear picture on our external environment. We learned not to simply predict the future, but to make it.
By year 6, our team had a good handle on the tactics needed to steer the business towards the metrics we wanted to win. We had accomplished 8 of our 9 goals and were in a dominant market position. Then came the second question. It wasn’t the bank loans (that was an exercise in determination). On Thursday night, looking at our historical earnings compared to the competition, I kept asking why do our results look different?
Our earnings over time varied wildly from quarter to quarter, but the other teams’ earnings were a straight linear trend, suggesting an even investment policy and a predictable return for investors. Even before we got our first bank loan, I sensed we were in serious trouble. Our tactics and short term strategies had given us predictable results on a two quarter horizon for the past 2 years, but the graphs hinted that we didn’t have a clear long term destination in sight. We knew how to move the business’s sails, but we were adrift at sea.
Throughout the competition, we threw around the “best provider” strategy without a concrete definition on what the business would ultimately look like with it. We kept our options open with an organic growth strategy without being committed (or aligned) to any particular vision of the future operations. We let our uncertainty about the future run rampant. A story without a plot.
In the last three quarters, we formulated a vision for what best provider meant to us: strong manufacturing presence in each marketing area with the goal of winning market share from our competitors through an extensive investment in training and lowering the price as our COGS decreased. We finished the competition with a market leading 24.6% market share and a clear path for the next few years. Other teams won the prizes, but we won an insight into strategy.
Two lessons that have held up in the ten years since.
The first is that strategy is essentially a story. Mission, vision, and objectives can help clarify elements into a standardized format, but an overarching narrative with a clear vision (or at least a guess) of the conclusion is necessary to keep from floating around aimlessly.
The second is that analysis is most useful for elements that you can’t change, like the past, or uncontrollable factors. It can help illuminate the area around you, but the path forward is in your control. Don’t just be a character in someone else’s story.
Also, don’t be late to meetings.
Here’s the email I sent my family during the intensive phase, when we went bankrupt twice in two days and had to explain ourselves to the board of directors at 2:30 in the morning.
In March I spent a weekend running test queries through Power BI Copilot on a Fabric F2 capacity, reviewing the diagnostic JSON to understand how to improve my reports. One of those diagnostics is the reason for this post. When I asked a question about product growth trends, the answer had taken 107 seconds to arrive. The DAX query behind it executed in 173 milliseconds. The other 106.8 seconds was LLM overhead, and the why didn’t exist outside of the diagnostics.
Copilot tackles a data question in two tiers. A semantic parser gets the first shot, matching your words against the model’s tables, columns, and relationships, and it plays by the model’s rules, so a column marked Hidden (IsHidden = 1) might as well not exist. My question needed Product[Category], which I had hidden. Refused. The question dropped through to the second tier, an LLM that writes DAX from scratch.
The two tiers inside answerDataQuestion. Tier 1 respects column visibility. Tier 2 doesn’t.
The DAX generator has auto-retry logic. If the generated DAX fails to execute, it tries again with a different approach, up to a limit. Mine hit the limit:
Three generation attempts, each with its own token cost. The first produced invalid GROUPBY DAX. The second tried a join with no common join columns. The third ran. Interestingly, the DAX generator does not respect column visibility the way the parser does (Microsoft’s documentation mentions this). The generated DAX reached the hidden Product[Category] through TREATAS without complaint. The tier that honors your configuration refused the question. The tier that ignores your configuration billed three attempts and then answered using the column you hid.
Four Layers
The reflex is to blame the question. Growth trends means comparing periods, comparing periods means real DAX, expensive question, expensive answer. The diagnostic says otherwise. A question of comparable complexity on the same model, four tables, three filters, three aggregations, resolved through the parser in about 2 seconds. The price didn’t come from what I asked. It came from a checkbox in the model view, and between that checkbox and the invoice there are four layers a user can’t see through.
They can’t see the visibility flags. IsHidden lives in the model properties, set by whoever built the semantic model, possibly years ago, possibly for cosmetic reasons. Hiding a column is how you tidy a field list. Nobody hides a column thinking they’re routing future questions to the expensive tier.
Where IsHidden lives. One toggle in the model’s Properties pane.
They can’t see which tier is handling the query. There is a tell, the loading message switches from “Checking the underlying data” to “Generating a DAX query,” but it only reads as a tell if you’ve been in the diagnostics. To everyone else it’s a progress message with vocabulary.
Left: the outer LLM insisting it doesn’t run DAX. Right: the fallback tier generating DAX. Same product.
They can’t see the retries. Three generations happened behind one spinner. The UI shows a wait, not an attempt count, and each attempt carried its own tokens at Copilot’s rates, 100 CU seconds per 1,000 input tokens and 400 CU seconds per 1,000 output tokens.
And they can’t see the cost land. Copilot operations are classified as background jobs, so they get spread across 24 hours. The 400 CU seconds from one query gets divided into roughly 16.67 CU seconds per hour across the next day. By the time consumption is visible in the Capacity Metrics app, the question, the retries, and the hidden column are a day in the past. The Capacity Metrics app is retrospective, not preventive.
Four layers, one property. Everything that determines the price sits upstream of anything the person paying can observe. The cost of a Copilot query is coupled to model hygiene, and model hygiene is invisible from the chat pane.
Why the Estimate Missed
The estimate.
The bill.
Before that weekend I went into the Azure calculator, pulled up the resource usage, all told would be no more than $5.00. The bill came to $18.78, and at the time I filed the gap under salespeople. That was unfair. The calculator estimates provisioned capacity, and it got that part right. The F2 itself cost $1.80 for the hours I ran it. The rest was consumption, and consumption is retry behavior multiplied by token counts. Retry behavior is determined by whether the semantic model gives the parser a clean path. No estimator can price whether somebody hid a column three weeks before you typed your question.
What the System Does
The purpose of a system is what it does. Nobody at Microsoft sat down and designed a meter that charges people for a checkbox they can’t see. The parser honors visibility because that’s the whole point of visibility. Retries exist because a second attempt beats an error message, and the smoothing is there to protect shared capacity from bursts. Every one of those choices is defensible on its own. Chain them together and you get a machine that spends 107 seconds of paid reasoning on a hidden column, then scatters the evidence across the next 24 hours. Configuration is priced in. Nobody sees the price until it’s been paid.
I built an MCP (Model Context Protocol) that gives an LLM five tools for working with a data lake: list tables, describe schema, sample data, row count, run query. I use it every day. Different data sets, different questions.
It supports different workflows. You can paste a screenshot, tell the agent “replicate this data and validate your query.” You can say “hey uhh this data point doesn’t look right. This repo has the code to generate it, can you tell me what’s up?” You can give it a query and tell it “I want to add this column but I need to thread it through all these CTEs. Can you add it?” It explores the database, writes SQL, checks its own results. Iteratively.
I’m proud of the MCP idea. I had the insight on a Sunday that those five tools in that configuration would be useful. The implementation took an afternoon.
It Works
A colleague was the first other person to test this out. He used it to produce an accurate query feeding a tool in a day that would have taken a week. SQL wasn’t his background, but in the process he learned the application, the data, and the business context, along with the relationships with the business users. That was the strategy. SQL isn’t as important as the domain. The MCP handles the SQL. It’s been playing out well.
Not just playing out well. The thing he built has been finding things worth investigating, the kind of things that accumulate in any system over time. It finds stuff because it checks its own work, reasons, asks questions. It’ll run a query, notice the count dropped unexpectedly after a filter, and investigate. It does exactly what a careful data analyst does, just faster. The AI does the mechanical part, but he’s understanding the business.
I’ve shared it with others now. This tool is clearly useful.
Mostly
One example made me pause. I had a query to write and ran the agent alongside my own work. It made its version in 10 seconds and, reading through it, it had done a completely different strategy than I had thought of. I wrote my own as a way to validate whether my internal approach was wrong or suboptimal. I’m okay with being wrong if I can learn. I’m not okay with staying wrong.
Our queries were generally similar. Mine was tighter, 85 lines to its 115. I also caught a bug it couldn’t see, a subtle data integrity issue where the agent’s approach was structurally wrong but by chance didn’t appear in the example we tested. Ultimately it didn’t matter.
I made an unrelated demonstration of the type of issue that can come up so you can understand the questions yourself, to get a sense for the context of problems this tool can solve.
Demo
Like many libraries, a regional library system with 470 branches across ten upper midwest states is launching a tool lending program, drills, saws, ladders, tile cutters. One existing branch per state will be selected as the tool depot for less commonly used tools, with deliveries to other branches when patrons place holds. The task: compute a circulation-weighted geographic center for each state using 2025 data, then select the nearest currently-open branch as the depot.
There are three existing tiers for the libraries on the system, which determine the processing network and priority for new book releases. Demographics and circulation mean that libraries can be switched between tiers.
In the database there are two tables. The branches table is an append-only log: every tier assignment, reassignment, and closure is a separate row. A branch that got reassigned from tier 2 to tier 1 has at least two rows. A branch that closed has a row with status = 'closed'. The circulation table has annual circulation figures by branch.
Table
Rows
What it is
branches
575
Append-only log. branch_id, name, city, state, lat, lng, tier, status, effective_date. Multiple rows per branch.
circulation
2,587
Annual circulation by branch and year. This is the weight.
The database the agent sees through the MCP.
The wrinkle: about 40 branches were reassigned between tiers. When a branch moves from tier 2 to tier 1, it gets an active record in the new tier. The old tier’s record gets set to closed, but due to operational lag, the closure is timestamped after the new assignment. Another 25 branches are genuinely closed.
The correct approach takes the last record per branch within each tier. If any tier’s latest record is active, the branch is open. This handles both cases: reassigned branches (closed in old tier, active in new tier) and genuinely closed branches (closed in their only tier, no active record anywhere).
State
Center Lat
Center Lng
Circulation
Branches
Depot
CO
39.5785
-105.3510
5,754,435
51
Lark Community Library, Lakewood
IA
41.9220
-92.7330
4,650,099
53
Buckeye Library, Marshalltown
KS
38.4919
-97.3810
4,625,767
49
Crane Library Branch, Salina
MN
45.2642
-93.3278
7,017,755
65
Summit Branch Library, Maple Grove
MO
38.6307
-92.9561
4,707,750
51
Sassafras Lending Library, Sedalia
ND
47.2868
-99.9350
2,540,057
25
Stone Memorial Library, Bismarck
NE
41.3372
-99.1227
5,247,031
46
Catalpa Library, Broken Bow
SD
44.2348
-100.6130
3,366,758
30
Pine Library, Pierre
WI
44.0521
-89.0220
5,835,967
55
Sumac Library Branch, Oshkosh
WY
42.4943
-107.2639
2,066,239
20
Valley Branch, Casper
Ground truth: circulation-weighted ton-centers by state, with nearest open branch as depot.
I pointed an LLM at the MCP and gave it the task.
The agent’s depot selections. Same ten branches as ground truth.
It produced a query that joined the branches table directly to circulation without resolving the append-only log into current state first:
The agent’s query. No deduplication of the append-only log.
There were two structural problems. First, no dedup: a reassigned branch with three log rows (initial assignment, new tier assignment, old tier closure) gets its circulation counted three times in the ton-center calculation. The weights are inflated and skewed. Second, it filtered WHERE status = 'active' to find open branches, which keeps genuinely closed branches, their original active record is still in the log, and the filter just drops the closed record that superseded it. 445 branches are actually open. The agent’s approach counts 470.
The ton-centers shifted. The depot selections didn’t. All ten states picked the same branch.
445 branches (black). Blue: ground truth. Red: agent query. You can barely see the gap.
The Ideal
The correct query. Resolve the log into current state by taking the last record per branch within each tier. If any tier’s latest record is active, the branch is open:
WITH per_tier AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY branch_id, tier
ORDER BY effective_date DESC
) as rn
FROM branches
),
latest_per_tier AS (
SELECT * FROM per_tier WHERE rn = 1
),
open_branches AS (
SELECT DISTINCT branch_id, branch_name,
city, state, lat, lng
FROM latest_per_tier
WHERE status = 'active'
),
ton_centers AS (
SELECT
bc.state,
SUM(c.annual_circulation * bc.lat)
/ SUM(c.annual_circulation) AS center_lat,
SUM(c.annual_circulation * bc.lng)
/ SUM(c.annual_circulation) AS center_lng,
SUM(c.annual_circulation) AS total_circ,
COUNT(DISTINCT bc.branch_id) AS num_branches
FROM open_branches bc
INNER JOIN circulation c
ON bc.branch_id = c.branch_id
AND c.year = 2025
GROUP BY bc.state
)
The PARTITION BY branch_id, tier is the key. Partitioning by branch alone picks the most recent record overall, which for reassigned branches is the closed record in the old tier, because the closure happened after the new assignment. Partitioning by branch and tier lets you see each tier independently. The old tier’s latest is closed. The new tier’s latest is active. The branch is open.
The agent didn’t know about the operational lag. It didn’t know that some branches have multiple log entries, or that status = 'active' doesn’t mean “currently open” when the table is append-only. It applied standard patterns, join, filter, aggregate, and got the same answer from a structurally wrong query. Pattern filling without contextual awareness.
An LLM is a pattern filler. The agent’s query was a reasonable starting point, and it landed on the same depot selections. I can’t tell you mine mattered.
Is a sandcastle good enough?
A lot of people I talk to have an existential unease about AI. Being good at something, then watching the definition of good shift under you in real time. I think there’s the real risk that people will forget that the part AI does is immediately a commodity. The artifact is ordinary, only as valuable as the tokens used to generate it, while quietly dropping the undocumented context that derisks it.
Is knowing the piece that the AI can do redundancy, or is it dangerous when its 80% contribution, without reflection, can initially pass for 100? As a developer, if you have ever tried to refactor a tangled mess of tightly coupled duplicative code that Claude has written, after it’s tried 10 iterations to implement a feature that constantly breaks another, or seeing its performance degrade from one version to the next, the worry about learned helplessness built on sand becomes unavoidable.
When I ran the query, the agent’s version had two structural bugs and produced the same result. Does it take my ability to write SQL and lived experience to anticipate those problems? Automating a task means nothing if it makes you materially wrong, but how do you know when you’re there? Chicken and egg.
This Post
I’ve been writing SQL since I was a child, and my parents enrolled me in classes at the local community college when I was 11 for programming. I don’t think I’ll lose that ability. It’s a native tongue.
But when I create tools like this, I cycle through “will this cause my skill to atrophy,” to “do they even matter,” settling on “can you evaluate without creation?”
When the tool says there’s something wrong? Often correct. When the tool makes an error? It’s usually slips or failures of a global mental model. The internal validation means the mistakes are edge cases not encountered yet. Sometimes it breaks something visible. But like you see above, sometimes it’s just potential.
I like crafting tight, efficient queries, but I’m also proud of this tool, making something that’s eliminated an entire class of problems. The benefit is not theoretical. I’m not worried about AI taking my job. If all the tasks had been simple enough for an AI to take, then the job wasn’t worth doing in the first place. They aren’t.
What AI does becomes the floor, and it does it without understanding. Patterns. My job isn’t to craft complex SQL queries. That is an effect, an output, evidence from a mental model.
This is a companion piece to How Power BI Copilot Works Under the Hood. In the main post I traced the full pipeline from question to answer, but one of the more practical findings was about the two paths Copilot can take to resolve a data question. One is fast and reliable. The other is slow, expensive, and prone to hallucination. Which path you land on depends on how your model is set up and how you phrase the question.
Two tiers, one question
When you ask Copilot a data question through answerDataQuestion, it first tries a semantic parser. This is a structured query engine that resolves your question against the model’s relationships, column names, and hierarchy levels. In the UI, you’ll see “Checking the underlying data…” while it works. The query I described in the main post (warehouse clothing sales in the US by month, joining four tables with three filters and three aggregations) resolved in about 2 seconds through this path.
If the parser can’t handle the question, Copilot falls back to generating DAX with an LLM. The UI shifts to “Generating a DAX query…” and the wait gets longer. One of my test queries took 107 seconds to generate DAX, then 173 milliseconds to execute it. The bottleneck isn’t running the query; it’s writing it. And because it’s freeform code generation, the results are less predictable.
So the practical question becomes: what keeps you in the first tier?
What the parser handles
The parser is good at questions that map to a single pass over the data with grouping, filtering, and basic aggregation. Think of it as roughly equivalent to what you could express in a single SELECT with GROUP BY. From the 24 queries I tested:
Simple aggregations work. “Total sales amount,” “average unit price,” “count of orders.” Filters on specific values work. “Sales in the United States” resolves because the parser matches “United States” to an actual value in the country column. Top N queries have native support; “top 10 customers by sales amount” stays in the fast path. Sorting works in both directions.
Multi-table joins work as long as the tables are connected through your model’s defined relationships. “Sales by customer country” resolves because sale→customer→country follows the relationship chain. The parser also has built-in relative date functions, so “last year” and “this year” work regardless of what dates are actually in your dataset. These are more robust than hardcoded years, which sometimes appeared in Copilot’s own suggested follow-up queries.
What surprised me was how much the parser could combine. That warehouse clothing query joined four tables (sale, reseller, product, sales territory, date), applied three filters (Warehouse, Clothing, United States), computed three aggregations (sum of quantity, average unit price, average discount), sorted by month, and resolved in 2 seconds. All in the fast path.
What falls through to DAX
The boundary is roughly: if the answer requires comparing results across different slices or building on intermediate calculations, the parser can’t express it. Year-over-year growth needs two separate period totals and a comparison between them. Percentage changes need the same. “Which products grew the most AND which declined?” asks for both sides of a ranking simultaneously, which requires multiple passes.
In my testing, every query that involved conditional logic across different analytical dimensions (“products where sales increased but returns also increased”) or multi-step analysis (“average sales for customers who bought more than 10 items”) fell through to DAX generation. The parser is single-pass by design; anything that needs a subquery or self-join is out of scope.
Setting your model up for the fast path
The single highest-leverage thing is descriptive names. The parser resolves natural language against your table, column, and measure names. sales_amount gives it something to work with; sa_amt_v2 does not.
Synonyms on hierarchy levels matter more than I expected. In the main post I documented a case where the parser refused a query about “category” because Product[Category] was hidden, even though the hierarchy level Product → Product → Category was visible. The parser resolves through hierarchy paths, not just columns. Adding a single Terms: ["category"] synonym to the hierarchy level turned a DomainModelLimitation refusal into a working query. One word.
Column visibility is important and asymmetric. The parser respects IsHidden; if a column is hidden, the parser won’t use it and may refuse the query entirely. The DAX fallback tier doesn’t always respect this boundary the same way. I found cases where generated DAX used hidden columns through TREATAS. This is another reason to prefer staying in the parser tier: it actually honors the access controls you set.
Field descriptions have a side effect worth knowing about. Setting descriptions on columns in “Prep data for AI” can trigger auto-generated synonyms. In one case, a field description caused a synonym to appear that made a previously-failing query succeed. The description itself wasn’t what fixed it; the synonym it generated was.
If you know users will ask questions that would normally fall to DAX (year-over-year growth, percentage changes), write measures for them. The parser can use pre-built measures in a single pass, turning a two-tier question into a one-tier question.
Phrasing
Refer to columns and measures by something close to their actual names. “Total sales amount” resolves better than “how much money did we make” if your measure is called Sales Amount. Be specific about filter values: “sales in the United States for clothing” gives the parser concrete strings to match against column values, where “US clothing revenue” requires it to infer that “US” means “United States” and “revenue” means “sales.”
Split compound questions. “What are the top customers and how have they changed over time?” is two questions. The first part stays in the parser; the second (change over time) forces a DAX fallback. Ask them separately and both answers come back faster.
One thing I didn’t expect: conversation context can poison the parser. If a query gets refused in one turn, the refusal persists in the conversation’s context events and can contaminate subsequent queries. I tested the same question in a session where a prior refusal existed and in a fresh session. It failed in the contaminated session; it succeeded in the fresh one. If Copilot starts refusing things it should be able to handle, start a new chat.
How to tell which tier you’re in
Watch the status text in the Copilot pane. “Checking the underlying data…” means the parser is working. “Generating a DAX query…” means it fell through. In the Service (browser) environment you’ll also see a “View DAX query” button when the DAX tier was used. Desktop doesn’t surface this, but the fallback still happens behind the scenes.
If you’re consistently seeing “Generating a DAX query” on questions you think should be simple, that’s a signal to check your model setup: column names, visibility, and synonyms. The parser’s capabilities are wider than you’d expect. Usually when it falls through, the model is the bottleneck, not the question.
Power BI is part of the Fabric ecosystem. Fabric is an end-to-end data platform, including ingestion, warehousing, outflows, and dashboarding, including Copilot for Power BI. The costing for Fabric involves you purchasing capacity units (CU), from F2 for 2, to F2048. Capacity units are measured in seconds. If you have 2 capacity units per second run, then you have 60 seconds available to you per 30 seconds of runtime. When I ran my workspace, I paid for 5 hours x 60 minutes / hour x 60 seconds / minute x 2 capacity units = 360,000 seconds, for a total of $1.80. But that’s not the full story.
Fabric bills Copilot at 100 CU seconds per 1,000 input tokens and 400 CU seconds per 1,000 output tokens. A request with 2,000 input tokens and 500 output tokens consumes 400 CU seconds, about 6.67 CU minutes. An average Power BI Copilot query in my instance ran about 1,500 CU seconds, though summaries have cost more. At the pay-as-you-go rate of $0.18 per CU hour, that’s roughly $0.075 per question. But the CUs come out of the same capacity pool that runs your Spark notebooks, warehouse queries, dataflows, and semantic model refreshes.
Regardless, you’re paying for fixed capacity, if you consume more than that then the service should throttle.
When you open a dashboard in Power BI or run a SQL query, the cost for that is spread out over the next 5 minutes to 24 hours. Copilot operations are classified as background jobs, so they get spread across 24 hours. This helps because it means the capacity you have running idle at 2am takes some of the cost of a burst of user queries at 2pm. This is a good thing.
That also means the 400 CU seconds from one query gets divided into roughly 16.67 CU seconds per hour across the next day. It’s also what makes the cost hard to notice until it accumulates. I ran my Fabric environment for 5 hours, but during that time, fabric was assuming that I would be running it for the next 24 hours.
If you have twenty queries a day from one user, 22 working days a month: about 660,000 CU seconds, 183 CU hours, roughly $33 in shared capacity consumption. Their Power BI Pro license costs $10. A team of ten moderate users on an F64 ($8,400/month) would put about 39% of the entire capacity toward Copilot alone. The remaining 61% handles everything else. The Capacity Metrics app is retrospective, not preventive.
The F2 made all of this visible because there was nothing else running. On a production F64, the same $17 of Copilot consumption would be a rounding error in a monthly bill that’s already four figures. Same cost. Same CUs consumed. Different signal-to-noise ratio.
I spent last weekend testing out Copilot for Power BI. I’ve been using it extensively as a developer, trying to make something that’s useful for people. I’ve been growing more skeptical of all my technical skills.
I went into the Azure calculator, pulled up the resource usage, all told would be no more than $5.00. Salespeople. I made the Power BI report, ran the Copilot queries. Documented the telephone game, deleted the resources.
Now I’m looking at what the telephone game cost. Not much, but for 5 hours for a single user running queries for only a small fraction of that time, a bill for $18.78. How did that happen?
I encountered the same when running my queries in Roo. This isn’t a post about money.
The Telephone Game
I showed in the last post that the Copilot pipeline is a chain: outer LLM rephrases the question, Q&A parser tries to match against the semantic model schema, if that fails a DAX generator fires as fallback, results go back to the outer LLM for formatting. Each step consumes CUs. Each retry consumes more.
The 107-second DAX generation with three retries was because a column got hidden in the semantic model. Metadata, not complexity of the ask. The Q&A parser kicked it to the DAX generator, which tried bad DAX three times before giving up. The diagnostic JSON shows all of it. Each attempt had its own token cost.
This is one tradeoff of a multi-agent system. Flashlight on the ground, looking for a problem that falls within its torch-light. Each node in the chain simplifies the problem to something it can solve. If you remove enough context or focus enough detail, anything becomes solvable. This means that four can chain together, with irrelevant details getting amplified. Telephone game.
The Q&A parser is good at what it does. The DAX generator is good at what it does. The handoff between them is where meaning drops out. That’s not a capability problem that gets fixed by making each node smarter. It’s a structural property of chaining agents.
Build vs. Run
The $18.78 bill on a restarted F2 capacity warrants another look, but not as a measure of cost, because if it means not hiring a third data analyst, then $3.70 per hour washes out. The problem is $3.70/hr/user. It runs counter to the trend of the last 40 years. Build vs Run.
Build. When you build a dashboard, it is a capital expense. Build it, done. Whether 5 people or 5,000 people look at it, the marginal cost per user is the same, nearly zero. If you use or rent a GPU, same.
Run. A Copilot query is an operating expense. Every question costs CUs. The more people use it, the more capacity it consumes. Until you invest in infrastructure or reserve capacity, success and cost scale together. Copilot answering “what were sales last month” for the 400th day in a row is going to incur 400 times the cost. The next person who asks pays again. To its credit, Fabric caches identical queries for 24 hours, though in my experience that also means you might get a “sorry Dave” question to multiple users if the first one had somehow triggered a refusal.
In between. I’ve seen people using Claude code to write the MCP tools where the tools exist and are deterministic after. The artifact is created and the marginal cost returns to zero.
The $0.91 in that Roo screenshot produced a working query and a colleague who now understands the data model. Copilot’s $0.075 produced an answer that evaporated when the session ended.
The Leverage Inversion
I went looking for data on how many people one person’s work typically serves.
Role
Ratio
How it works
Cashier
1:1
Sequential. One customer at a time.
Customer service (phone)
1:1
Synchronous. One call.
Customer service (chat)
1:3-5
Multiplexed. Concurrent sessions.
Nurse (acute care)
1:4-6
ANA recommends max 6 patients.
Teacher (OECD avg)
1:15
One-to-many, synchronous.
IT help desk
1:70-100
Gartner benchmark. Mix of sync and async.
Dashboard builder
1:dozens-hundreds
Asynchronous. Artifact serves people without you.
Software engineer
1:thousands-millions
Code is leverage. WhatsApp: 50 engineers, 1B users.
The low end is Baumol’s stagnant sector. William Baumol and William Bowen, 1966: the string quartet can’t play faster. Four musicians, same duration, every performance. The performer’s labor IS the output. You can’t increase productivity because there’s nothing to optimize away. But wages rise anyway because the progressive sector (manufacturing, tech) pulls them up. So the cost of live performance, healthcare, education rises without the work itself changing, called Baumol’s cost disease.
Software was supposed to escape this. Build once, serve at scale. Zero marginal cost of replication. Naval Ravikant calls code “permissionless leverage”: it works for you at scale without needing anyone’s permission. WhatsApp served 42 billion messages daily with a team that fit in a conference room. That’s what breaking out of the string quartet looks like.
The consumption model puts something interesting on the table. The “performance” (the query, the generation, the agent session) has a real cost every time it runs. A dashboard builder at 1:500 creates an artifact once and it serves viewers indefinitely. Copilot answering questions about that same dashboard is a separate interaction for each person, each question. The leverage ratio looks different.
“Pay for what you use” was cloud computing’s promise, and it was a good one. The meter was on your infrastructure. A VM you spun up, controlled, sized, shut down. This meter is on something else. Every person who asks a question, anywhere in the org, adds to the same shared capacity pool. The consumption is emergent. It’s the aggregate of individual curiosity across the entire company, billed to a shared resource nobody is watching at the question level.
Google handled this differently. Gemini is bundled into all Workspace plans at a flat rate, $7-26/user/month depending on tier. Prices went up 16-22% when Gemini was added. No per-query metering. Everyone pays whether they use it or not. Microsoft gives you the per-query meter but lets you opt out by not enabling Copilot. Different risk transfer models for the same underlying cost. When employees have access to both Copilot and ChatGPT, 76% choose ChatGPT. Both companies’ real moat is forced bundling with the productivity suite.
The Design Question
The consumption cost is proportional to how much reasoning you’re asking the LLM to do per interaction. That much seems clear from the data.
Copilot’s Q&A parser tier is a good example. Cheap pattern matching handles the easy stuff. If the user asks “what were total sales last month” and the semantic model has a measure for that, it matches directly. The LLM only fires on fallback. That tiered design keeps the per-interaction cost low for the 60-70% of questions that are straightforward. The same principle shows up in the MCP: five deterministic tools, the LLM picks which one and interprets results. Most of the work is in the tool, not the reasoning.
Contrast that with overly broad agent instructions. “You are a helpful data analyst, figure it out” with a dozen tools. The LLM is in the hot path for everything. Every instruction that could have been a tool, every decision that could have been a rule, every piece of context that could have been in the schema metadata, that’s reasoning you’re paying for per query.
People at my company are building Copilot Studio agents that encode their instructions into a prompt. That’s middle management in a different medium. Compare that to a coworker who wrote a script that bundles an entire repo into a file you can paste as context to an agent, with response parsing built in. One fills the existing pattern. The other gives people something they didn’t have before.
The “just make an agent” thing. A virtual employee that can be cloned infinitely sounds like an advantage until you notice that everyone else can clone one too. If the advantage can be replicated at marginal cost, it’s table stakes within six months. The question underneath that: what work stays valuable when everyone has agents?
Where I keep landing: general-purpose insight extraction layered on specific artifacts. Build a table, the AI uses it to answer a range of questions. Build a Gantt chart, the AI applies it to a suite of problems. The artifact is still the leverage. The AI is an interpretation layer on top. My colleague isn’t valuable because he has an agent. He’s valuable because he understands the business well enough to know which questions to ask and whether the answers make sense.
This isn’t only about LLMs either. World models, spatial reasoning, domain-specific architectures for things like protein folding. None of these are LLMs and all of them are moving fast. Yann LeCun left Meta and raised over a billion dollars betting that LLMs “will become useless within five years.” Whether he’s right about that is a different post. The design principle is the same regardless: minimize the expensive reasoning surface area, build specific tools underneath. If you build around a model type, you’re locked in. If you build around the principle, the reasoning layer is swappable.
The Turn
“Is It Okay” ended uneasy. The meter doesn’t help with that.
But the Power BI tool is important. It lets people find insights faster than I could. I’m not the bottleneck anymore. My colleague learned the domain through the MCP in ways he couldn’t have without it. He’s finding discrepancies nobody had surfaced before. That’s real. The MCP isn’t designed to teach him the business, but that’s what it does.
I entered the workforce at a time where the challenge was how to survive in a world where the model was front-load on education and hope it translates to experience before you retire. Drucker’s knowledge worker problem. Continuous learning as the strategy. Now I’m in a world where finding information is cost zero, but finding context is the whole game. The strategy was continuous learning. What’s the model for continuous experience?
Jack Dorsey cut Block from 10,000 to 6,000 employees and shipped 40% more production code per engineer. Tobi Lutke told Shopify employees to prove AI can’t do a job before asking for headcount. Salesforce stopped hiring software engineers. The investment thesis is headcount reduction. The “augmentation” framing and the “replacement” framing aren’t in tension. They’re the same companies saying both things in different rooms.
McKinsey says 6% of enterprises qualify as “AI high performers.” PwC says 56% of CEOs report AI has produced neither increased revenue nor decreased costs. S&P Global says 42% of companies abandoned most AI initiatives in 2025. IBM found that GenAI ROI averaged 31% in pilots but collapsed to 7% when scaled, below the typical 10% cost-of-capital hurdle. These numbers don’t mean AI doesn’t work. They mean the deployment model matters, and most organizations haven’t figured it out yet.
Both things are true at the same time. The consumption model changes the economics. And the tools let people do work that wasn’t possible before. There’s a version of this where the cost analysis is the whole story and the conclusion is “it’s bad.” There’s another version where the tools story is the whole story and the conclusion is “it’s great.” Neither version is honest.
The Landing
The purpose of a system is what it does.
The system isn’t designed to hide costs, but the 24-hour smoothing makes them hard to see. The system isn’t designed to invert leverage, but per-query consumption does change the math. Microsoft didn’t design Fabric to be opaque about Copilot costs. The Capacity Metrics app exists, the CU rates are published, the smoothing mechanism is documented. But the purpose of a system is what it does, and what the system does is make it very easy to not notice.
And the MCP isn’t designed to teach my colleague the business, but that’s what it does. The tool isn’t designed to surface discrepancies nobody found before, but that’s what it does.
Same principle. Both directions.
Look at the systems you’re building. Not what they’re designed to do. What they actually do.
There’s an aphorism, the confidence about replacing a job with automation is highest when you are least familiar with it. For AI, in my domain, that’s been my experience. It can write SQL queries that run, but miss the context. It can scaffold out a Pytorch model, but in a tightly coupled overcommented mess. If you can one shot it, the code is great. Otherwise prepare for the slog.
There’s a push and pull. You can see the rot happening when teams are pushed to use it entirely for development. I’m feeling the pressure to shoehorn it everywhere into my workflows. I’m left trying to protect future Jonathan on a Friday night. How can I use the 80% it can do well, but design a system that is efficient, and doesn’t sink the ship in the process. My systems won’t rot.
Task
I’m at the stage of learning French where I can read the newspaper or a novel, but not without stopping every couple of paragraphs to look something up. Solid B1, creeping toward B2. Every few lines there’s a word that could mean three different things depending on context, and if I just guess, I’m probably going to learn the wrong meaning and carry it around for months.
The gold standard is looking it up and I have a 30-40 minute session daily where I look up words, write them down, do analytics later. The act of actively searching for a word builds stronger neural pathways than having someone hand you the answer. At the same time, you can’t use that approach 100% of the time. It’s cognitive strain maintaining it. When I’m reading on my couch, pick up my phone, open an app, type the word (with accents I don’t have memorized on the keyboard), read the definition, and then find my place again… I’m not reading anymore. I’m doing vocabulary drills that happen to be interrupted by a novel.
I wanted something in between. Not a flashcard system, not a study tool. A way to keep reading without stopping too much, and without filling in the gaps wrong from context.
Concept
I started with a concept. I set my phone on the counter, or the table, or wherever I’m reading. When I hit a word I don’t know, I shout it out loud. The page would recognize the French, look up the definition, display it, and read it back to me. My eyes would never leave the page.
It isn’t optimal for retention. I’m trading memory strength for reading flow. But the goal isn’t to memorize every word on first encounter. It’s to get through 40 pages instead of 12, and to not build a mental dictionary of wrong definitions by guessing from context clues that I’m not advanced enough to read correctly yet. Get to an hour of reading beyond the standard practice. The goal isn’t the new word to be learned, it’s reinforcing the other words as part of a sentence in new contexts.
Webpage
Straight across the plate. It would be a local app, single HTML file. I gave Claude a description of what I wanted and got back a 660-line HTML file that worked on the first run. Single file, no framework, no build step. It uses the built in Chrome voice recognition and an anonymous MyMemory translation API for French-to-English lookups, and in browser TTS to read it back. Simple.
Looking up “entre guillemets” (between quotation marks). Multi-word phrases work too.
Phrases work too. “Entre guillemets” yields “between quotation marks.” That was a phrase that’s been rattling through my head because I hear it a lot on television (the news recently). Saying “dispositif de secours” gives me “backup device.” If I’m somewhere I can’t talk out loud, or the recognition is mangling my pronunciation on a specific word, there’s a text input as a fallback.
The whole thing runs on GitHub Pages. No server, no cost, and I can pull it up on my phone’s browser while I read.
Code Analysis
The app worked, but some points crept up that I’ve seen when reviewing PRs at work too.
Commenting
Almost every block has a comment. // State above the state variables. // DOM Elements above the DOM queries. // Check browser support above the browser support check. // Timeout fallback in case onend never fires (browser quirk) above the timeout fallback. // Estimate ~100ms per character at 0.9 rate, plus 2 second buffer above the math that does exactly that.
The system makes sense. The main issue with comments and documents is that it’s easy to make, impossible to maintain. If an LLM is able to make and update comments, and it helps as useful metadata for a downstream to read it, maybe it isn’t a problem. But in this instance, they describe what the next line does, not why. The kind of comments you’d delete in code review because the code already says it.
State Flags
The approach to concurrency was to add a boolean. Six flags at the top of the script: isListening, isProcessing, isSpeaking, pendingWord, lastProcessedWord, currentTTSTimeout. This is a three-state machine (listening, processing, speaking) implemented as a bag of independent booleans that have to be manually kept in sync. Every function checks two or three flags before deciding what to do. It works, but it’s the kind of thing where adding one more feature means touching every function.
Tight Coupling and Separation of Concerns
handleWord() updates the UI, manages API calls, state flags, interrupt detection, TTS triggering, and history. Six jobs. There’s no separation between “figure out the translation” and “update the screen” and “manage the audio pipeline.” If you wanted to swap the translation API, you’d be editing the middle of a 60-line function that also handles the queue logic. The code reads top to bottom like a script, and to its credit, the flow is clear. But it’s procedural, not structured.
Though this one is counter to how I’ve normally seen its approach. When people code, I notice premature abstraction. Someone writes a data connector for a specific REST api, reasons they need to have one that actually should handle any number of internal APIs, or that it should be a general purpose data ingestion function, or more general purpose data provider function, … ultimately becoming a mess of overabstracted logic when a specific function would have fared better, even if there was theoretically some technical debt. AI usually flips this, making a bunch of concrete interconnected pieces that are nearly impossible to reason through. An AI can hold 15 objects in its mind while implementing a new change on a function, a human reviewer can’t.
Broken Features
Getting speech recognition to work was straightforward. Getting it to work continuously was a different story.
The first version worked fine for one word. Say “bonjour,” get a definition, great. Say a second word and nothing happened. The app looked alive. The button still said “Stop Listening,” the status dot was green. No response.
While the app processed a word (the API call, then the text-to-speech playback), a flag blocked all incoming speech. Anything I said during that window got dropped silently. No error, no feedback, just gone. And the browser’s text-to-speech onend event sometimes just doesn’t fire. Known quirk, no fix. When that happened, the flag stayed on forever and the app was bricked until I refreshed.
It got worse before it got better. At one point the microphone was re-prompting for permission on every recognition cycle. The app started catching its own TTS output and trying to look up its own definitions in an infinite loop. We didn’t just screw the pooch. Basically every dog in the neighborhood.
Problem Fixing Process
I asked the AI to analyze the problem first, and it nailed the diagnosis: six potential causes, correctly prioritized, with the right recommendation (let speech interrupt TTS). Then I told it to implement the fix.
It added three more state flags. lastRestartTime to throttle restarts. restartFailCount for exponential backoff. isStarting to prevent overlapping start attempts. The restart function went from 4 lines to 20, with timing checks, failure counters, and a “too many rapid restarts, stopping” error message. Net change: +41 lines.
This made things worse. More flags meant more edge cases, more timing windows where flags disagreed, more ways for the state to get stuck. I spent a 22-message session debugging the debugging.
The actual fix was the opposite: I deleted almost everything it had added. Removed all three new flags. Removed the backoff logic. Removed the duplicate word detection. The restart function went back to 4 lines. The real solution was simpler: stop the microphone during text-to-speech, restart it after. No timing, no counters, no tracking. The commit was -88 lines, +34 lines. The app ended up shorter than the initial generation despite having more functionality.
Same pattern with gender detection. The AI built a suffix-matching heuristic for French noun gender (words ending in “-tion” are feminine, “-age” is masculine) and used it to prepend “un” or “une” to translation results. The badge in the UI? Fine, helpful visual hint. Prepending articles to verbs and adverbs? Not fine. I told it to remove the article logic entirely. A heuristic accurate enough for a colored dot is not accurate enough for constructing grammar.
I tried adding English text-to-speech for the translation portion, so it would say “femme signifie” in French and then “woman” in English. Switching TTS languages mid-sentence didn’t work in any browser I tested. Killed it after one session.
Overall Patterns
Every correction I made was a deletion. The AI’s instinct, when something broke, was to add machinery. More flags, more tracking, more edge cases, ironically more brittleness. My instinct was to find the simpler fix that made the machinery unnecessary. At work I’ll often cherry pick/break up functions and use them, and sometimes the most sensible solution is to fail. It solved problems by building around them. I solved them by removing the complexity around them.
The initial generation is often good. It gets me from concept to working app in one prompt, and the architecture is readable even if it was tightly coupled. But the debugging revealed that consistent bias. It writes like someone who’s read a lot of code but hasn’t maintained any of it. “Will this work” at the expense of “will this be easy to change later.” A human coder understands their limited capacity to hold things in their head. It drives simpler and more robust solutions. If an AI has a million token context window, why engineer anything that is less efficient? It can always figure it out later.
For a side project I use on my couch while reading French novels, that’s completely fine. But if I were building something larger, I’d treat the output the way I’d treat a first draft as a way to challenge my initial approach. Sometimes the AI writes code in a way I’d never think of, but often in a way I should never think of. I’m working on understanding the 80% that works. The judo of redirecting the majority of the code into logical units that are easy to reason through and maintain.
It’s a personal project. I can read my book without stopping. That has to count for something.
Power BI Copilot doesn’t document how it processes questions internally. The closest that I’ve found is in Microsoft Learn (below). I’m writing what I’ve found using Copilot’s diagnostic JSON across three environments, Desktop, Power BI Service (not edit mode), and the sidebar Copilot in Power BI.
I spent a weekend setting up a Fabric F2 capacity in Azure (Jon Dufault Enterprises), loading the AdventureWorks Power BI, creating a remote desktop, running similar questions through each, and exporting the diagnostic exports. Everything in this post comes from those exports.
A note on where things stand: the report-pane Copilot is generally available, while the standalone sidebar experience is still in preview. Microsoft’s official overview describes capabilities at a high level. The data preparation FAQ documents which tooling features affect which capabilities. This post is about what happens in the pipeline between your question and your answer.
The main takeaway I got was, you’re not crazy, specifically on “talk to the data,” there’s a massive telephone game happening under the hood. If it goes right then it’s magic. When it doesn’t, chaos.
Below includes inference alongside the facts. Please let me know if you find anything inaccurate.
Power BI & Copilot
Microsoft added the ability to run Copilot in 2023, which is meant to replace the Q&A feature for Power BI. There’s a sidebar you can click to chat with the report and the data like you were using Copilot:
Power BI report with Copilot
I’ve heard mixed feedback on it from other teams, though I’ve found it useful, my users too. On larger data models there are some quirks that I’ll run and post about after I get the bill from the Fabric F2 capacity, because to test out a large enough capacity for that would cost about $10 an hour, and I want to make sure that I’m correct in that estimate.
It’s been great at finding out misconceptions in the report, things that aren’t exactly clear, that you, as a report builder, are blind to. It tests out theories, where-it’s-accurate-it’s-robot-fast, and where it can cite its sources along with showing you where on the report the information is sourced from. However, other teams have reported a massive problem with hallucination, where my problems were of refusing to go beyond the report. What’s causing that?
The Conversation Pipeline
This is the structure I saw across the chats regardless of the platform:
The general structure in the Copilot logs
The user asks a question, an outer LLM takes that, and instructions and data, restates what they said (sometimes dereferencing words like it and that), and provides clarified information and context to a tool that it picks.
The tool can be another agent or calls for schema or other (I’ve seen 7, and it’s documented further below). Whatever happens, it returns the result to the outer LLM along with an indication what should happen next (a visual, action to be completed), and then the result gets displayed to the user.
As a report builder, you have some, limited influence on the process. 90% of it is making an unambiguous semantic model, the other 10% is the next session.
Tuning the AI
This section is just getting us on a common language. You can skip past it if you’re familiar with how it works.
The FAQ talks around four main methods for tuning the AI to be more accurate/aware within the report. Custom Instructions, Verified Answers, AI Data Schema, and Metadata. Microsoft recommends you work in that order. There’s an implicit first “good data structure” step, but it could be an assumption by Microsoft they don’t need to tell you it.
Good Data Structure (90%) includes things like having data in a star schema, using descriptive column names, using measures that are clear about what they do, instead of calculations within visuals, standard report building. The goal is “can an outsider come in and read this?” because Copilot is basically that.
Custom Instructions are set in the “Add AI instructions” tab under “Prep data for AI.” They’re stored in copilotModelSettings.CustomInstructions (more information below). Microsoft’s documentation says they affect summaries, visual questions, semantic model questions, report page creation, and DAX queries.
The three part “prep data for AI” includes simplifying the data schema, verified answers, and custom AI instructions.
Field Descriptions are set on columns and measures in the model properties. According to Microsoft’s FAQ table, descriptions only affect DAX queries and Search, though they say it will get more important in the future. Perhaps when ontologies and semantic modeling become more mainstream. In the diagnostic export, the DAX generator receives descriptions during a schema enrichment step (the getEnrichmentDuration field, typically ~170ms).
The Category field with a description and a synonym. The description reaches DAX queries. The synonym reaches the Q&A parser. Different channels, different tiers.
AI Data Schemas let you select which tables and columns Copilot can see. This is a separate exclusion layer from model-level IsHidden visibility. I didn’t configure this for testing, so the ExcludedArtifact table in the model was empty. Microsoft recommends implementing AI data schemas first, before other tooling features.
Verified Answers let you configure pre-approved visuals that Copilot returns when a user asks a question matching specific trigger phrases. I didn’t test verified answers. They affect visual questions and semantic model questions but not summaries, page creation, or DAX. Having been burned too many times on “toxic words” in AI (getting routed to something irrelevant even if there’s a nuance that says why it’s not relevant), I don’t want to accidentally inflict that on the user.
Microsoft’s FAQ provides this capability matrix:
Capability
AI data schemas
Verified answers
AI instructions
Descriptions
Get a summary of my report
No
No
Yes
No
Ask about visuals on report
No
Yes
Yes
No
Ask about semantic model
Yes
Yes
Yes
No
Create a report page
No
No
Yes
No
Search
No
Yes
No
Yes
DAX query
No
No
Yes
Yes
The Outer LLM and Tool Dispatch
Every Copilot interaction starts with an LLM that receives your message, the conversation history, and a set of tools it can call. The outer LLM rephrases your question and dispatches it to a tool.
The outer LLM sees:
Your message and the full chat history (prior turns including tool responses)
Custom AI instructions
The model schema (from copilotModelSettings.Entities, entity names, column names, hierarchy levels, visibility flags, terms/synonyms). Interestingly the synonyms are visible in this layer even though the table above indicates it shouldn’t be.
Tool definitions (the schemas for answerDataQuestion, reportSummary, and whichever other tools are available in that environment)
(And I’m guessing) a system prompt (not visible in the export, but inferred from the agent’s behavior, this is what tells Desktop’s LLM it “can’t run DAX”)
So for this input:
Query
The outer LLM took those 5 pieces of information, interpreted what I said, and called a reportSummary tool with a summarized version:
{
"role": "assistant",
"tool_calls": [
{
"id": "call_[long_redacted_id_string]",
"type": "function",
"function": {
"name": "reportSummary",
"arguments": "{\"instructions\":\"Provide a high-level summary
of the key insights and trends visible in this Power BI
report. Focus on the main metrics, any notable patterns,
and significant changes or outliers.\"}"
}
}
]
}
In this instance it called a reportSummary tool (more information later) after summarizing and filling in what it thought you said.
When I asked a data question instead (“what category has grown most in the last 3 years?”), the LLM picked a different tool and rephrased the question before dispatching it to the Q&A endpoint:
{
"function": {
"name": "answerDataQuestion",
"arguments": "{\"userUtterance\":\"Which product category has
experienced the highest growth in sales over the last 3 years?\"}"
}
}
The rephrasing happens on every call. “Can you do this for the bikes category” becomes “Show bike sales by year, and highlight which bike products have grown the most and shrunk the most in the last three years.” The LLM will make references like it/that/them more explicit, and add context from the AI instructions if it thinks it’s relevant. This is the only way the downstream tool gets input from the user.
The Tools
I found seven tools across the three environments. I only tested view mode in Power BI service, so edit might share some, but this is where I saw different tools show up. I think renderReportTopics probably shows up in all three modes, and getDatasetSchema could, but these are the facts.
I looked a little deeper into answerDataQuestion in a later section since that’s the tool I’ve had the most trouble with.
Tool
Desktop
Service (in-report)
Service (sidebar)
What it does
answerDataQuestion
Yes
Yes
Yes
Routes the question through a multi-tier query engine
reportSummary
Yes
Yes
Yes
Reads up to 20 visuals on a report page and generates a summary
getDatasetSchema
Yes
Retrieves model schema + custom instructions for the LLM (target: assistant, invisible to user)
createPageV3
Yes
Creates a report page with a defined layout. In both examples it was 2 slicers, 2 cards, and 4 visuals.
renderReportTopics
Yes
When it comes back to you and makes you choose from a list of items
discoverItems
Yes
Searches the tenant for relevant reports and datasets
The sidebar needs discoverItems because it has no implicit report context. When the sidebar Copilot receives a question like “sales last quarter,” it first has to find a report to answer from:
{
"llmTargetedContent": "Items relevant to query \"sales last quarter\":
[{\"displayName\":\"AdventureWorks Sales\",
\"relevance\":\"SomewhatRelevant\",
\"matchedSignals\":[\"Recents\"],
\"Type\":\"powerbi-report\",
\"Id\":\"bcaf35ea-...\"},
{\"displayName\":\"AdventureWorks Sales\",
\"Type\":\"powerbi-dataset\",
\"Id\":\"fb720484-...\"}]"
}
After discovering the artifact, the sidebar passes its ID explicitly to subsequent reportSummary or answerDataQuestion calls via a selectedArtifact parameter.
Three Agent Identities
Each environment has a different agent identifier in the diagnostic export.
Field
Desktop
Service (in-report)
Service (sidebar)
CopilotAgentId
PuPr_Desktop_ReportAuthoringAgent_202508
PuPr_Service_ReportConsumptionAgent_202408
(absent)
consumptionMethod
Power BI Desktop Report View
Power BI Web App
Power BI Web App
serviceVersion
/minerva
13.0.27916.20
13.0.27916.20
clientVersion
(absent)
2603.2.28388-train
2603.2.28388-train
NL-to-DAX caller
ReportQnA
ReportQnA
Immersive
The naming convention encodes the role. ReportAuthoringAgent has page creation tools. ReportConsumptionAgent is oriented toward reading and summarizing. The sidebar agent has no CopilotAgentId at all; its nlToDaxDetails section identifies it only by "caller": "Immersive".
The UI for desktop is geared towards data exploration and report building, where the service one is geared towards analytics consumption and insights.
Desktop: three toggleable skills.
Service in-report: task categories.
Telephone Game: answerDataQuestion
This is the tool that I love and hate the most about Power BI, because Microsoft has added so many intermediaries that when it goes right, it’s excellent, but there’s a lot of room for one AI agent or another to mess up.
This is my message:
{
"role": "user",
"content": "what customers are the biggest for accessories category",
"metadata": {
"createdAt": "3/14/2026, 7:51:46 PM"
}
},
The outer LLM restates what I said, and calls answerDataQuestion:
"tool_calls": [
{
"id": "call_[redacted]",
"type": "function",
"function": {
"name": "answerDataQuestion",
"arguments": "{\"userUtterance\":\"What customers have the highest sales in the Accessories category?\"}"
},
"runtimeExecution": 0
}
]
},
The answerDataQuestion restates it and writes a Data query based off that:
"interpretResponse": {
"restatements": [
"Show customer, where country-region is United States, category is Accessories, and full date is not empty sorted by sales amount by due date descending"
],
"content": "Power BI Q&A responded to the user by displaying the following textual answer: Based on the available data, the largest Accessories customers in the US are Nathan Lal, James Wright and Autumn Li, with Nathan Lal leading. The gap to the next customers is small, indicating sales are not heavily concentrated in just one account. Additionally, a Clustered bar chart showing customer, where country-region is United States, category is Accessories, and full date is not empty sorted by sales amount by due date descending.",
Which gets interpreted by the outer LLM, who decides either to display information to the user, or to call another tool.
This is the general setup I understand:
answerDataQuestion tool
More on the Tiers:
Priority 1: The Q&A Semantic Parser Tier
The first tier is a structured query engine. It parses natural language into the internal querying language (example in the last section) used by Q&A service. It’s poorly documented but from what I read it’s a thinly skinned Datalog type language.
The loading message in the UI says “Checking the underlying data…”
Every request to this parser is tagged ["Copilot", "LlmParser"] in the interpretRequest. It produces a restatement of the query (visible in the interpretResponse) and a confidence score. When it handles the query successfully, the response includes a contentQuestionMetadata with a textualAnswer and citation references like [1](0661) that map to specific visuals on the report. This happens on both Desktop and Service.
When it can’t handle the query, it returns one of:
QueryNotSupported warning: the parser doesn’t know how to express the question. This triggers the Priority 2 fallback.
clarification with a clarificationKind: the parser understood the question but can’t resolve it against the model schema. Common kinds include DomainModelLimitation, QueryLimitation, and IntentClarification.
The parser respects column visibility. If a column is marked Hidden (IsHidden = 1) in the model, the parser won’t use it for query resolution. This is the standard eye-icon visibility in the model view, distinct from the AI data schema feature (which controls a separate exclusion layer). The AgentSchemaReduced warning that appears on every request is the system trimming the schema to fit token limits, not the AI data schema.
Priority 2: The NL-to-DAX Generator Tier
When the Q&A Semantic Parser returns QueryNotSupported, the system falls back to an LLM-based DAX generator. The fallbackReason field in the diagnostic confirms this:
The loading message in the UI changes to “Generating a DAX query…” when this tier activates.
The DAX generator has auto-retry logic. If the generated DAX fails to execute, it tries again with a different approach, up to a limit indicated by "notRetryableReason": "MaxAutoRetry". Here’s an example from a Desktop session where asking about product growth trends required three attempts:
"daxGeneration": [
{
"daxQuery": "[large dax query with groupby]",
"errorDetails": "Function 'GROUPBY' scalar expressions have to be
Aggregation functions over CurrentGroup()."
},
{
"daxQuery": "[large dax query with naturalleftouterjoin]",
"errorDetails": "No common join columns detected. The join function
'NATURALLEFTOUTERJOIN' requires at-least one common join column."
},
{
"daxQuery": "[dax query with addcolumns + filter]"
}
],
"daxExecution": {
"autoRetryCount": 2,
"notRetryableReason": "MaxAutoRetry",
"executeDaxDuration": 172.5
},
"generateDaxDuration": 106814.5
107 seconds for DAX generation (including retries). 173 milliseconds for execution. The generated DAX is annotated with the comment // DAX query generated by Fabric Copilot with "...".
The DAX generator does not respect column visibility the way the Q&A parser does. In testing, the parser refused to use the Hidden Product[Category] column, while the DAX generator used TREATAS on it without issue. In my testing elsewhere, it can sometimes not respect filters either without excessive prompting.
Citation behavior across environments
Both Desktop and Service generate citation references (like [1]) in their responses when the Q&A parser handles a query. These citations map to specific visuals on the report page. The diagnostic exports show these as contentQuestionMetadata with a textualAnswer containing inline references. Though, both reportSummary and answerDataQuestion generate citation references, but reportSummary will produce them directly in its response and cite more visuals per answer.
The presentation differs. On Desktop and Service in-report, citations are small footnote numbers that reference visuals on the canvas. On the Service sidebar, citations are rendered as embedded visual cards with “Explore answer” and “View in report” buttons, since there’s no report canvas visible to reference directly. Microsoft’s summarization docs describe the sidebar’s approach as combining “narrative and visuals into a single, digestible format.”
The sidebar embeds visuals inline with numbered citations and action buttons.
Evidence: How Instructions Flow Through the Pipeline
To see the full pipeline in action, I added a custom instruction: “unless the user specifically specifies a country, add ‘in the united states only’ to the question.” Then I asked “what is the order quantity total?”
Here’s what happened at each layer when the instruction was followed:
Custom Instructions:
"CustomInstructions": " \n\n This is a sales model for Adventure Works, a bicycle manufacturer. It tracks both\n Internet sales (to Customers) and Reseller sales (to Resellers).\n\n Key guidance:\n - \"Sales\" or \"Revenue\" means SUM(Sales[Sales Amount])\n - \"Cost\" means SUM(Sales[Total Product Cost])\n - \"Profit\" means SUM(Sales[Sales Amount]) - SUM(Sales[Total Product Cost])\n - \"Top customers\" means customers ranked by SUM(Sales[Sales Amount]) descending\n - Product categories are: Bikes, Components, Clothing, Accessories\n - \"For each category, show top customers\" means: group by Product[Category],\n then within each category rank Customer[Customer] by SUM(Sales[Sales Amount])\n - Internet sales have CustomerKey > 0; Reseller sales have ResellerKey > 0\n - The Date table uses a fiscal year starting July 1\n - Only the OrderDate relationship is active; use USERELATIONSHIP for DueDate/ShipDate\n - only include country-region united states unless otherwise specified by the user",
My Message:
{
"role": "user",
"content": "what is the total order quantity",
"metadata": {
"createdAt": "3/14/2026, 7:03:20 PM"
}
},
Outer LLM (no changing in language nor mention of United States)
"tool_calls": [
{
"id": "call_[redacted]",
"type": "function",
"function": {
"name": "answerDataQuestion",
"arguments": "{\"userUtterance\":\"what is the total order quantity\"}"
},
Q&A Restatement:
"restatements": [
"Show total order quantity, where country-region is United States"
],
"content": "Power BI Q&A responded to the user by displaying the following textual answer: Based on the available data, the total order quantity for the United States is 21,344 units, representing the full aggregated order volume for this country under the applied filters. Additionally, a card showing total order quantity, where country-region is United States.",
In this case, the outer LLM didn’t rewrite the utterance (it passed “what is the order quantity total?” through verbatim), but the Q&A parser picked up the US filter on its own. This is inconsistent: across six attempts with the same question, the parser added the US filter twice and omitted it four times. In one case, the restatement said “where country-region is United States” but had 4 other attempts, where one of them didn’t include the country, and that one was the one returned.
Context Poisoning
The Q&A parser receives prior conversation turns as contextEvents in the interpretRequest. When a refusal occurs, it enters the context and affects subsequent queries:
"interpretRequest": {
"tags": ["Copilot", "LlmParser"],
"conversationalContext": {
"contextEvents": [
{
"utterance": "Which customers have the highest sales amount
for the Accessories category in the last three years?",
"responses": [
{
"command": {
"clarification": {
"clarificationKind": "DomainModelLimitation",
"message": "I'm not able to answer this exactly as
asked because your data model doesn't include a
clear category field on the sales lines..."
}
}
}
]
}
]
}
}
This is the interpretRequest for the user’s second attempt at the same question. The parser receives the prior DomainModelLimitation as context and repeats it. Every subsequent question in the session received the same refusal.
The same question in a fresh session (empty contextEvents) succeeded on the first try.
Microsoft recommends using the “clear chat” button when “switching topics to avoid overloading Copilot with unrelated prior context.” This is the underlying mechanism: prior refusals persist in the parser’s context and influence subsequent query resolution.
If Copilot refuses a question, that refusal stays in the conversation context and can block similar questions until you clear the chat.
Auto-Generated Synonyms
In early sessions, the copilotModelSettings.Entities showed "Terms": [] (empty) on every entity. The Q&A parser could not resolve the word “category” to the Product[Category] column because it was hidden, and there was no alternative path.
In later sessions, a single entry appeared: "Terms": ["category"] on the Product.Products.Category hierarchy level, which was visible. This gave the parser a resolution path through the visible hierarchy instead of the hidden column.
This synonym was not added manually. It appeared after I added a field description to the Category column. Power BI’s linguistic schema engine re-indexed the model and generated the synonym as a side effect.
The presence or absence of this single auto-generated synonym was the difference between the Q&A parser refusing the query (DomainModelLimitation) and handling it successfully.
Desktop-Specific: DAX Awareness
The Desktop agent’s outer LLM does not appear to know that answerDataQuestion generates and executes DAX internally. When asked directly to “run a DAX query,” the outer LLM responded without calling any tool:
“I do not execute DAX code directly or return live query results.”
The outer LLM’s response. No tool was called.
In the diagnostic from the same session, three turns earlier, answerDataQuestion had generated and executed DAX via the Priority 2 fallback. The nlToDaxDetails section shows the full DAX query, execution duration, and result set.
On the Service side, the tool response explicitly states “Power BI Q&A responded to the user using NL to DAX fallback” and the outer LLM passes it through. The sidebar also surfaces a “View DAX query” button in the UI. Desktop does not.
Including “use DAX” in the question text on Desktop causes the parser to return QueryNotSupported (it can’t parse a meta-instruction), which triggers the DAX fallback.
How to Export and Read the Diagnostic Data
In any Copilot pane (Desktop or Service), click the three-dot menu (…) at the top right and select “Export diagnostic data.” It’s a json file.
The export contains:
chatHistory: the full conversation (user messages, tool calls, tool responses)
dataQuestion: the internal pipeline for each answerDataQuestion call, including interpretRequest, interpretResponse (with warnings and restatements), and nlToDaxDetails (with every DAX generation attempt, errors, retries, and timing)
copilotModelSettings: the schema sent to Copilot, including entity names, column visibility, Terms/Synonyms, and Custom Instructions
reportContentCopilot: visual timing data for report summaries
Microsoft’s summarization docs mention the diagnostics as a way to check visual query timings. The export contains considerably more.
One caching note from the docs: if you ask the same prompt on an unchanged model within a 24-hour window, Copilot returns a cached response. Clearing the chat doesn’t reset this. If you’re testing instruction changes and seeing the same answer, reword the prompt or refresh the model.
Observations
The loading messages in the UI indicate which tier is active. “Checking the underlying data” means the Q&A parser is working. “Generating a DAX query” means the NL-to-DAX fallback has been triggered. “Scanning report content” means reportSummary is reading visuals.
Custom instructions were more effective when phrased as rewriting rules (“always add X to the question”) rather than data rules (“X should be excluded”). Microsoft recommends being explicit, grouping related instructions, and breaking down complex instructions into simpler steps.
Field descriptions reach the DAX generator but not the Q&A parser. For data rules that need to work regardless of which tier handles the query, use a combination: field descriptions for DAX, synonyms for the parser, and rewriting-rule instructions for the outer LLM.
Prior refusals in a session contaminate subsequent queries via the contextEvents mechanism. Clearing the chat resets this.
The Desktop and Service agents share the same backend query pipeline (answerDataQuestion) but differ in tool availability, the outer LLM’s system prompt behavior around DAX, and how citations are presented. Microsoft’s recommended implementation order for data preparation is: AI data schemas, then verified answers, then AI instructions, then descriptions.
Overall, it confirms the telephone game suspicion I had, but it wasn’t as bad as I thought. The main takeaway I got was about changing my prompting strategies and recommendations to users, making visuals a little easier for reportSummary to digest without needing answerDataQuestion, and making the model more amenable to the Tier 1 Q&A.
If you stare at any two datasets long enough, you can convince yourself there’s a connection between them. Not because there is, but because there is an important enough question that the data “should” be connected. It’s a dangerous place from which to start a modeling project.
This is one such story. Enter multi-instance-learning, and how I failed spectacularly even on simulated data.
Business Context
Imagine an operation where some things are measured obsessively, but others are scattered. Precise timestamps on every step of a process. How long did each phase take? Detailed duration metrics on every transaction, high volumes every day.
Separately, you run satisfaction surveys. Happy people interacting with your operation isn’t just a people thing. If you have a reputation for wasting someone’s time, you’re going to quickly find yourself paying more for the privilege. Making your operation a place people want to come is as good people sense as it is business.
Not everyone takes the survey. It’s voluntary, and the responses come in throughout the day, timestamped but not tied to any specific transaction. Someone fills one in right after their interaction, and someone else two hours later, or the next morning. You don’t know which transaction prompted the response.
In this scenario, we wouldn’t want to know what specific transaction prompted that response, and that isn’t important. We would like to know what conditions prompted it. If you found out that for whatever reason blue paint in the waiting room made people happy? Then blue paint it would be.
The question I was wanting to answer: can we link those two data sources? If we could add contextual information to the survey, we could identify the operational metrics that actually matter to the people filling them out. Instead of guessing that long wait times hurt satisfaction, we’d have data. We could focus on the metrics that matter and surface them in operational dashboards, and have a balanced scorecard approach.
I decided to throw a neural network at it. This is the story of why that was the wrong tool for the job.
The Architecture
The problem breaks down into two pieces you have to solve at once. You get a survey with a timestamp and a score, and somewhere in the hours before that survey, there’s a set of transactions that could have caused it. Which one was it? And what about that transaction made them rate it the way they did? You can’t answer one without the other. To learn what drives bad scores you need to know which transaction provoked it, but to know which transaction to look at you need to know what bad scores look like. Chicken and egg.
I went at this two ways. First attempt was two networks trained together. One network looks at all the candidate transactions in a time window and assigns probability weights to each one, like saying “I think it was 60% likely to be transaction #47 and 25% likely to be transaction #52.” The other network takes a transaction’s duration metrics and tries to predict the survey score. They share a loss function, so when the score predictor gets it wrong, that error signal also teaches the matcher to pick better candidates next time.
Second attempt used something called Multiple Instance Learning, where you treat all the candidate transactions as a bag. Instead of picking one candidate, the model weighs the whole set, builds a blended representation, and predicts the score from that. More mathematically principled for this kind of “I don’t know which item in the group is the important one” problem.
Both are reasonable approaches. Neither was why things went sideways.
The Synthetic Proof-of-Concept
The exercise was built on proving out whether this could extract the signal from the noise when I knew there was a signal. I built a synthetic dataset with a known ground truth. 1,000 transactions, 200 surveys, a 30-minute candidate window. The scoring rule was deterministic: start at score 5, subtract points if any of four duration metrics exceeded their thresholds, floor at 1.
Each survey was generated by randomly selecting a transaction and adding 2-30 minutes of delay. So I knew exactly which transaction caused each survey and I knew the exact formula that produced each score. No noise. No ambiguity. A few candidates per survey because the window was tight. If the model couldn’t crack this, it couldn’t crack anything.
It got 85% of scores right and matched the correct transaction 80% of the time. Sounds decent until you remember this is a cheat sheet test. The formula is deterministic and there are maybe 4 candidates to pick from. Missing 15% of scores on that is not great. I looked at the training curves and it was classic overfitting. Train set accuracy going up, test set stuck and jittering around 75%. The model was fitting to the training examples rather than learning the pattern.
The synthetic version that actually learned something. The real data told a different story.
That was the first red flag and I mostly ignored it.
Scaling Up and Falling Apart
I then tried to make the data look more like what you’d actually face in practice. Scaled to 10,000 transactions and 5,000 surveys. Widened the candidate window to 600 minutes. In practice, people don’t fill in surveys within 30 minutes. They do it hours later, sometimes the next day. A 600-minute window gave me about 40 candidates per survey instead of 4.
Five score categories means guessing randomly gets you 20%. We barely beat random on scores. And 2.7% matching against 40 candidates is literally what you’d get from a coin flip (random chance is 2.5%). The model trained for 200 epochs and came out the other side knowing nothing it didn’t know before epoch 1.
I switched to the MIL architecture. Loss went from 2.3 down to 1.6 over 65 epochs. Looks like progress on paper, but it’s a common trap: looking at loss functions and not considering what the model is actually doing with individual predictions. I pulled out the transactions the attention mechanism focused on most for each test survey and grouped them by score level.
Score 1 transactions had average durations of 10.5, 7.6, 27.1. Score 5 transactions had average durations of 11.7, 7.5, 26.5. Basically the same numbers. The attention wasn’t locking onto anything meaningful. It picked whoever was convenient, and the score predictor just learned to always say “about 3.5” because that minimizes your loss when you have no real information.
What I learned
Three problems killed this, but any one of them would have sufficed.
The Mechanical. The matching is looking for a needle in a haystack where all the hay looks exactly like the needle. Forty candidates in a window, all with duration metrics drawn from the same distributions. The correct transaction has no distinguishing mark. The only thing that makes it “correct” is that its durations happen to match the scoring formula, but the model doesn’t know the formula yet because that’s what it’s trying to learn. It’s stuck in a loop. You’d need something like a transaction ID on the survey, and if you had that, you wouldn’t need a model at all.
The data collection. This one took me too long to see. A person filling out a survey isn’t reacting to one interaction. They’re reacting to their morning. Their week. How things have been going in general. The whole premise of “which transaction caused this score” assumes a 1-to-1 link that doesn’t exist. In practice, the extremes (1s and 5s) tend to reflect overall sentiment or first impressions, while the middle scores (2-4) are more nuanced. The survey is a thermometer, not a receipt.
The business context. Even if you could match perfectly, a handful of duration numbers aren’t enough to explain why someone rates a 3 versus a 4. Experience depends on how people treated them, physical conditions, whether things were ready when they arrived, the weather. Duration is a proxy for some of that (long waits often signal a disorganized operation), but a rough one. Predicting 5-level satisfaction from timing features was always going to cap out.
The Actual Answer
The hypothesis behind all of this was something like: “if we reduce wait times, satisfaction goes up.” That’s a perfectly testable idea. But not with a model.
Building a neural network to reverse-engineer causality from observational data is the hard way to answer this. The easy way: pick a set of locations, implement a change at half of them, leave the rest as controls, compare survey scores three months later. If cutting time moves the average score meaningfully, there’s your answer. The question becomes quantifying the value of that improvement and whether the cost pencils out. If it doesn’t budge, that’s also useful, and a lot cheaper than training models that converge to random, or worse relying on their recommendations.
That’s the unglamorous conclusion. I spent time on attention mechanisms and MIL architectures when the right approach was a spreadsheet and a pilot program. I was trying to shortcut around the hard part (actually changing operations and measuring the result) by mining historical data for patterns that would predict the outcome. But the signal was never in the data because nobody designed the data collection to put it there. Surveys and transactions are two streams that happen to coexist in time. No amount of matrix multiplication will manufacture a causal link the measurement system never established.
Sometimes you just have to run the experiment. Change the process and see what happens. No model required.
I’ve been getting more into vision analytics, private, professional, everywhere. My usual approach for solving problems is to learn a tool, understand conceptually what it does, check my back catalog of problems I couldn’t solve and see if that tool or approach helps. Vision analytics is no different.
I’ve also applied this to document classification problems, imagine a thick scanned packet where you need to find specific data elements within specific pages among dozens of irrelevant ones. For the start, we trained a PyTorch model to filter out irrelevant pages in the packet based off how they “looked.” You don’t need to read the fine print to tell a calibration certificate from a cover sheet. I want to hone in on something we did for that project.
A simple example, every ML tutorial skips the annotation step. You get “collect your data” and “train your model” with nothing in between. The in-between is where I’ve spent most of my time on this project. How do we really solve this problem?
Off the shelf tools I’ve found include LabelImg for bounding box annotation with YOLO export, and Label Studio for more general-purpose labeling including classification. You can also save a bunch of files to a folder and manually drag things over, like I did for my Puss in Boots classifier. Each one of them requires you to learn a system that includes features not relevant for what you’re doing.
Training data. Just folders of images before annotation.
For me, I knew the inputs (big folder of images), the outputs (the standard for YOLO and COCO are relatively clear). What was available to me? Touchscreen laptop, ways of interacting that I like, apps that I’ve liked using (e.g. I like bounding boxes you can click on to select, resize on the corners and not the outside of the box, being able to reclass by clicking the class again, …). What design choices work for me in the app are different than other people.
With that, I built two versions of a bounding box annotation tool in tkinter. Both take images from an input directory, let you draw and label rectangles, and save bounding box coordinates in standard formats. The tool uses the touchscreen extensively, and the design choices you can see are all built for making that workflow simple, and simple for my brain.
The first version of the annotation tool. Functional, if not pretty.
Each annotation is a set of bounding box coordinates (class, position, size), one file per image. The tool manages file state: images go from data/input/ to data/processed/, annotations save to data/annotations/. An MD5 hash index checks each incoming image against already-processed files to prevent reannotating duplicates.
Design Decisions
These came from annotating about 200 images on a touchscreen tablet.
Touch targets. Default tkinter handle sizes are too small for fingers. I set HANDLE_SIZE to 20 pixels, EDGE_TOLERANCE to 15, BUTTON_HEIGHT to 50. At the original sizes I was missing resize handles about 40% of the time on the touchscreen.
Nested boxes. The cat detector needs both full-body and face annotations, meaning a smaller box inside a larger one. Click detection uses edge proximity: within EDGE_TOLERANCE pixels of an existing box edge means selection. Deeper inside means start drawing a new box. This solved the sub-annotation problem without adding a mode toggle.
Auto-advance. Save and Next moves the image to processed/ and loads the next one. Saves roughly 4 seconds per image. Over 600 images, that’s 40 minutes of file management that the tool handles instead of me.
Version two. Class panel, box inventory, and buttons you can actually hit with your fingers.
V1 worked. V2 arose as I worked with the tool, noting every bit of hesitation I had with the interaction. I added undo/redo with a 50-action stack and a panel listing every box and its assigned class.
From Cats to Documents
For training images that just need a class label (like the original document classification problem), it’s still the same tool pattern, but in a different domain. I made another while thinking about how the project would work for documents. I in fact did this in the initial pass for the Puss in Boots detection, though at that scale, I started needing to be judicious about class balancing in the later rounds.
For single image detection, keyboard tagging makes way more sense. I made a tool with Tkinter where it goes through the folder and displays it. You type F for Finn, B for Bandit, L for Luna. Same idea for documents: C for contract, I for invoice, R for receipt, S for skip. Image comes up, I press one key, next image loads. Peak throughput was about 1.5 seconds per page.
Same pattern, different domain. Single-keystroke classification for cats.
I’ve used the same pattern for document classification pipelines.
What’s the point?
I built these for myself not because that off-the-shelf tools can’t do classification. It’s that I can quickly and scrappily build something that identically matches how I’m already conceptualizing the process. Every shortcut, undo, go back, skip, exists because I hit that exact friction point while annotating. I add the stuff that makes it easy for me to do something extremely quickly, because the tool is just automating the way I’m already thinking about it. No translation costs accumulating.
I don’t have to constantly think “okay, click, move the mouse to a point I wasn’t thinking about.” There’s no translation step between the decision in my head and the action the tool takes. That matters more than it sounds like it should. Ruts aren’t always a bad thing. Scale is easy to achieve if you’re working in them.
A process that changes something, but closely enough to match the muscle memory of someone performing the task gets adopted. One that asks them to rethink their mental model on every interaction, doesn’t.
End Result
The tradeoff has always been between simple tools that work for most users (think things like coreutils in linux), more specialized powerful tools that work for an individual user (this post), and general purpose tools that work for everyone. The second two choices are becoming less of a distinction. Tkinter isn’t complex. It’s something that can be automated. Simple tools with reasoning and input a user can be glued together to make unreasonably powerful tools when paired with that user. The gap between “I need this tool” and “I have this tool” was two hours of tkinter and basic installs. That gap is getting smaller.
Google’s been exploring an idea like this with Generative UI, where Gemini builds bespoke interfaces on the fly instead of showing everyone the same one. It’s the same process at a larger scale, since the question is built on “I need this output, but I want you to understand how it would be most straightforward for me to do it.”
Annotated training data becomes a real-time detector.
Ultimately, the tools I built here aren’t polished. They’re precisely fit. They’re held together with tkinter and duct tape, but that’s the point, built on the fly to match the shape of how I already think about the problem, so there’s no tax on every interaction. And that’s the real lesson here. The next generation of tooling isn’t going to be about building one perfect interface. It’ll be about making it trivially cheap to build the right interface for the person sitting in front of it.