Writing Reports
A report pulls together several results -- a table, a map, a model's fit -- into a document someone else can read. This tutorial gathers them with base.bundle, has ai.report write the report, and renders it with viz.report_html and viz.report_pdf. You can also write the report yourself; step 3 shows what one looks like.
1. Bundle the results🔗
An input takes one output, so a block that needs several results from across the pipeline takes a bundle:
- id: "@bundle"
name: base.bundle
inputs:
- { block: "@plot_totals", output: result, as: a }
- { block: "@plot_map", output: map, as: b }
- { block: "@fit", output: result, as: c }
args:
names: plots,map,fitnames names the connected slots in order. Names become table names in the report's SQL, so they are identifiers: letters, digits and underscores. The web editor fills slots in order as you connect blocks.
2. Have ai.report write it🔗
- id: "@write_report"
name: ai.report
inputs:
- { block: "@bundle", output: bundle, as: bundle }
args:
description: >-
A one-page summary of the plots for the landowner: how many there are,
mean biomass per plot, the map, and a chart of biomass by state.
provider: anthropic
model: claude-opus-5-5
share: summary
secrets:
llm: my-anthropic-keyThe agent decides what to say and how to lay it out. It never writes a number from your data itself: every figure is a named statistic it defines as a query over the bundle's tables, and the renderer computes it. At share: schema the agent writes around the statistics without ever seeing their values; at summary it can see them, which helps it phrase things ("about twice the state mean").
3. What the report source looks like🔗
ai.report outputs a directory -- the same thing you would write by hand:
report/
report.md
specs/
by_state.vl.json
stats.jsonreport.md:
# Plot summary
There are {{stat:n_plots}} plots, with a mean biomass of {{stat:mean_biomass}} t/ha.

stats.json:
{
"n_plots": { "sql": "SELECT count(*) FROM plots" },
"mean_biomass": { "sql": "SELECT avg(biomass) FROM plots", "format": ".1f" }
}{{stat:<name>}}is replaced by the value of that statistic's query, run over the bundle's tables by member name, formatted with the optional Python format spec.places an image from the bundle;renders a chart fromspecs/(a Vega-Lite spec whose data names are bundle members).- Markdown tables work; raw HTML does not -- it is shown as text.
To use a report you wrote yourself, upload the directory (spade data upload report/) and connect a platform.upload_directory block to viz.report_html in place of ai.report.
4. Render it🔗
- id: "@report"
name: viz.report_html
inputs:
- { block: "@write_report", output: report, as: report }
- { block: "@bundle", output: bundle, as: bundle }
- id: "@report_pdf"
name: viz.report_pdf
inputs:
- { block: "@report", output: html, as: html }
args:
page_size: letterThe HTML is a single self-contained file: every figure is inline, nothing loads from anywhere, and it is sanitized, so it is safe to open and to share. The PDF is printed from that same HTML.
A report that cites a statistic stats.json does not define, or an image that is neither a bundle member nor a saved spec, fails viz.report_html with a message naming it -- ai.report checks for the same mistakes before it accepts its own draft.