Skip to main content
Tutorial

Building Reports with Claude Code

Overview

This page follows one report from a question to a committed directory, then changes it a few times. The point is to show what the conversation actually looks like, because the design loop is where most of the value is and it looks nothing like filling in a report builder form.

You need the CLI installed and connected first. If uc-bq schema --list does not yet print your tables, start with Getting started.

Ask the question

Open Claude Code in the directory holding .ultracart-bq.json and type what you want:

Show me my top 10 products by revenue for the last 90 days

Claude Code works through this in a fixed order, and it shows you each step.

It starts by finding out what it has to work with, using uc-bq schema --list and then reading the schema of the tables that look relevant. For this question that is uc_orders, where the line items sit in a nested items array, order timestamps are in creation_dts, and there is a partition_date column that exists purely to keep queries cheap.

Then it writes the SQL. The generated query looks roughly like this:

SELECT
item.merchant_item_id AS product_id,
item.description AS product_name,
COUNT(DISTINCT order_id) AS order_count,
SUM(item.quantity) AS units_sold,
ROUND(SUM(item.total.value), 2) AS total_revenue
FROM `ultracart-dw-demo.ultracart_dw.uc_orders`,
UNNEST(items) AS item
WHERE partition_date >= DATE_TRUNC(DATE_SUB(@start_date, INTERVAL 1 MONTH), WEEK(SUNDAY))
AND partition_date <= DATE_TRUNC(DATE_ADD(@end_date, INTERVAL 1 MONTH), WEEK(SUNDAY))
AND creation_dts BETWEEN @start_date AND @end_date
AND current_stage != 'Rejected'
AND payment.test_order IS NOT TRUE
GROUP BY 1, 2
ORDER BY total_revenue DESC
LIMIT 10

Several conventions in there are the skill's doing rather than yours. The date range arrives as BigQuery named parameters instead of literals, so the same query can be replayed for any period. The partition_date filter is paired with the real creation_dts filter, which lets BigQuery skip whole partitions and is the single biggest factor in what a query costs. Rejected and test orders are excluded by default, because almost nobody wants them counted.

It tests the query with uc-bq query, showing you the rows and what the query cost. Then it writes a formatChartData function for the visualization, renders it with uc-bq render, and performs a first full run:

uc-bq run top-products-by-revenue --no-analysis

Look at what you got

./reports/DEMO/top-products-by-revenue/
├── report.yaml # parameters, chart type, config, run history
├── query.sql # the parameterized SQL above
├── chart.js # the formatChartData function
├── analysis_prompt.md # instructions for writing future analysis
├── chart.png
├── chart-dashboard.png
├── report.md # the executive analysis
├── report.pdf # chart and analysis combined
└── data.json # this run's results

analysis_prompt.md is worth understanding, because it is easy to mistake for the analysis itself. It is not. It is a set of instructions, written for this specific report, telling a model how to interpret this data later: which fields matter, what concentration or pricing patterns to look for, and what kind of recommendation is useful. When the report runs unattended months from now, that file is what makes the generated commentary about your business rather than generic.

report.yaml also records the prompt you originally typed, so the reason the report exists survives the person who asked for it.

Commit report.yaml, query.sql, chart.js, and analysis_prompt.md. Everything else is rebuilt on each run.

Change the date range

Ask in ordinary terms:

Can you run it again but for just January?

Claude Code replays it with an override rather than editing anything:

uc-bq run top-products-by-revenue --start_date=2026-01-01 --end_date=2026-01-31 --no-analysis

The chart and PDF are rebuilt for January and the report's own defaults are untouched, so the next ordinary run goes back to the last 90 days. To move the default permanently, ask for that instead, and it edits the manifest or uses uc-bq config set-param.

The defaults themselves are usually relative expressions such as -90d and today, which is why a report stays current without anyone maintaining it. The full list is in the Configuration reference.

Fix the layout

The chart is really wide and gets squished in the PDF. Can you make it landscape?

There are two answers and Claude Code will offer both. For one run:

uc-bq run top-products-by-revenue --landscape --no-analysis

For every future run, it sets landscape: true under analysis: in the manifest, and the flag is no longer needed. This distinction comes up constantly: a flag changes one run, the manifest changes the report. Ask for whichever you meant.

Change the visualization

Ask for a different chart and Claude Code rewrites chart.js and updates the chart.type field:

Make this a horizontal bar chart sorted by units sold instead

The available types are bar, line, area, stacked-bar, stacked-area, pie, donut, scatter, heatmap, treemap, funnel, gauge, radar, candlestick, and boxplot.

Each chart function is written to handle two sizes. At full size it produces the chart you asked for; at 200 by 200 it produces chart-dashboard.png, a stripped-down tile with no legend or axis labels and a single headline number. That is the version used in dashboard grids, and it is why tiles stay readable when a full chart would be unreadable at that size.

Geographic questions work too:

Show me revenue by state as a geo map

Maps cover the United States only. A question about another country needs a different chart type.

Bring in data from outside UltraCart

If your advertising spend lives in its own BigQuery project, you can ask for reports that span both:

I also have marketing data in a separate Google project. Can you pull that in?

Claude Code walks through registering the project, then handles questions like show me ROAS by Google Ads campaign for last month as ordinary reports. The registration steps are in External data sources.

Ask for it to be sent somewhere

Can I have this report emailed to my team and posted to Slack automatically?

Claude Code adds a delivery section to the manifest with the channel and recipients you name. You still have to create the Slack app and set the API keys yourself, since those are credentials. See Report delivery.

When a query is too expensive

If a query would scan more than the safety limit, the run stops before it costs anything and prints what it would have processed. The right response is almost never --force. Ask Claude Code to tighten the query instead:

That query is scanning 45 GB. Can you narrow it down?

It will usually add or tighten a partition_date predicate, which is what makes the difference between scanning a table and scanning a few weeks of it. Cost protection and the override flags are described in the Configuration reference.

What you end up with

A directory of text files that produce a chart, a PDF, and a written analysis, and that anyone can re-run without Claude Code, an API key, or a subscription. Reviewing a change to how revenue is defined means reading a diff on query.sql in a pull request.

Next steps

Was this page helpful?