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,fit

names 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-key

The 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.json

report.md:

# Plot summary

There are {{stat:n_plots}} plots, with a mean biomass of {{stat:mean_biomass}} t/ha.

![Plot locations](bundle:map)

![Biomass by state](spec:by_state)

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.
  • ![caption](bundle:<member>) places an image from the bundle; ![caption](spec:<name>) renders a chart from specs/ (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: letter

The 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.