Writing Report Definitions

Last update: September 14, 2026
•
1 min to read

🧪 Experimental feature (preview). This feature is under active development and released early so you can try it and give feedback. Behaviour, options, and results can change without notice, and it isn’t covered by the usual support and stability guarantees. Avoid relying on it for production work.

Overview

A report definition is one JSON object that describes the structure and appearance of a Report: its layout, the colour of the canvas, the title block, the tiles on the grid, and any annotations floating over them. It contains no data rows. Every tile points at the same source — "context", the records of the page the reader is looking at — and the report profiles and aggregates those records itself when it renders.

This page is for whoever writes that JSON: a person pasting a design in, or a model asked to draft a report instead of a human. It is the contract. Two companion pages go deeper:

  • Report Chart Reference — every chart type, the fields each one reads, the shape of data it needs, and what the renderer does to the data on the way in.
  • Designing a Report from Data — the recipe for turning a column profile into a good report: which columns to chart, which chart to use, palettes, sizes, and how to write a title and summary that say something.

The chart primitives page describes the charts themselves, independent of the report.

Where the JSON lives. Open a set of records, switch the view to Report, and click the JSON segment in the toolbar. The text box shows the current definition. Edit or paste, press Apply, and the report re-renders from what you wrote. Copy takes the current definition out. Nothing goes to the server: the definition is saved in the browser, keyed to the shape of the data you were looking at, and comes back when you return to a list with the same columns. Save the report as a Space to keep it properly.

A complete, working definition

This renders as-is against any list that has the named columns. It shows all three grid tile kinds, a filter, a legend override, and one annotation.

{
  "version": 1,
  "layout": "grid",
  "theme": "white",
  "title": {
    "show": true,
    "title": "Case Events Overview",
    "body": "<p>247 case events across 6 event types, 4 categories and 5 regions, Jan–Jun 2026. The charts show volume by type, the category split, amount by category and region, and activity over time.</p>",
    "logo": "hubstream"
  },
  "tiles": [
    { "chartType": "bar", "sourceId": "context", "dimension": "EventType", "measure": "", "aggregation": "count",
      "size": "L", "palette": "tableau", "theme": "light", "orientation": "left", "title": "Events by Type" },

    { "chartType": "donut", "sourceId": "context", "dimension": "Category", "measure": "", "aggregation": "count",
      "size": "M", "palette": "tableau", "theme": "light", "orientation": "right", "title": "By Category" },

    { "chartType": "heatmap", "sourceId": "context", "dimension": "Category", "seriesField": "Region",
      "measure": "Amount", "aggregation": "sum",
      "size": "M", "palette": "viridis", "theme": "light", "orientation": "left", "title": "Amount: Category × Region" },

    { "chartType": "timeSeries", "sourceId": "context", "dimension": "CreatedOn", "granularity": "month",
      "measure": "", "aggregation": "count",
      "size": "L", "palette": "viridis", "theme": "light", "orientation": "left", "title": "Events per Month" },

    { "kind": "hubtile", "tile": "numberLabel", "sourceId": "context", "measure": "Amount", "aggregation": "sum",
      "label": "Total amount, open cases", "filter": { "field": "Status", "operator": "equals", "value": "Open" },
      "size": "S", "palette": "tableau", "theme": "light" },

    { "kind": "list", "title": "Recent high-value events", "sourceId": "context",
      "fields": ["EventType", "Region", "Amount", "CreatedOn"], "groupBy": "Region", "maxItems": 12, "navigate": true,
      "filter": { "field": "Amount", "operator": "gt", "value": "10000" }, "autoGrow": true,
      "size": "M", "palette": "tableau", "theme": "light" }
  ],
  "annotations": [
    { "id": "anno-1", "type": "notecard", "xPct": 62, "yPx": 120, "text": "Q2 spike is the Northern region audit.", "color": "#fff7a8" }
  ]
}

Everything not shown is optional and falls back to a sensible default. The loader is deliberately tolerant — an unknown chart type becomes a bar, an unknown theme becomes white, a half-written filter is ignored rather than blanking the tile — so a partial or slightly wrong definition still renders. That tolerance is what makes the format practical for a model to draft. It also means mistakes are silent: watch for a tile that came out as a bar when you asked for something else.

The envelope

FieldValuesNotes
version1Always 1 today. Write it; nothing checks it yet.
layout"grid" (default) · "column"grid is a 12-column grid where tiles take 4, 6 or 12 columns by size. column stacks every tile full-width in one centred column, ignoring size.
themea canvas theme id — see belowAnything unrecognised loads as "white".
titleobjectThe title block. See below.
tilesarrayThe grid tiles, in display order. There is no reordering in the UI, so the order you write is the order readers see.
annotationsarrayStickers and notes floating over the report. Usually [].

Canvas themes

theme sets the colour of the page the tiles sit on and the title text. Nine solid colours (no gradients, so the PDF export paints cleanly), separated by hue so they tell apart at swatch size. Each theme also decides whether the tiles’ cards default to light or dark.

themeLabelBackgroundTitle textCards default to
whiteWhite#ffffff#1a2035light
parchmentParchment#f3e6c8#3a3016light
sageSage#cbe0c0#1e2e19light
skySky#c2dbf7#13253elight
blushBlush#f6d0d8#3d1d24light
dark-blueMidnight Blue#0d1e3d#e7eefbdark
forestDeep Forest#0e2a19#e3efe6dark
plumPlum#2a1030#f3e6f5dark
blackBlack#0b0c10#eef1f6dark

A tile that omits its own theme takes the canvas theme’s card default, so on dark-blue every tile is a dark card unless you say otherwise. Picking a theme in the toolbar also flips every tile’s card to match. Set a tile’s theme explicitly only for deliberate contrast.

Gotcha. "theme": "light" and "theme": "dark" are not theme ids. Older definitions and the built-in auto-builder write them; they load as white. Use the ids above.

The title block

"title": { "show": true, "title": "Case Events Overview", "body": "<p>…</p>", "logo": "none" }
FieldValuesNotes
showtrue (default) · falseHide the block entirely. Only an explicit false hides it.
titlestringThe heading. Rendered as a single line; keep it short.
bodyHTML stringThe summary under the heading. It’s a rich-text editor on screen, so the value is HTML, not markdown.
logo"none" (default) · "hubstream" · "dataspace"Logo drawn above the title. It inverts automatically on the dark themes.

What to put in body. The editor’s own toolbar produces bold, italic, bulleted lists, text colour and highlight, so stick to that vocabulary and it will round-trip through the editor unchanged:

<p>247 records across 7 fields. <b>Event Type</b>: 6 values — Seizure (120), Complaint (80), Referral (30).</p>
<ul><li>Spanning <i>Jan 2026–Jun 2026</i>.</li><li>Total Amount: <span style="background-color:#fff15c">1,234,567</span>.</li></ul>

Allowed and safe: <p>, <b>/<strong>, <i>/<em>, <ul>/<li>, <br>, and <span style="color:#hex"> / <span style="background-color:#hex">. Keep it to one or two short paragraphs; the block is a header, not a page.

What every grid tile shares

There are three kinds of grid tile. kind says which; a tile with no kind is a chart, because chart tiles predate the other kinds and old definitions have no kind at all. The builder writes chart tiles without one, too.

FieldValuesApplies toNotes
kindomitted or "chart" · "list" · "hubtile"allUnknown values load as chart.
sourceId"context"allThe one data source: the records in view. Anything else is replaced with it.
size"S" · "M" · "L"allGrid columns out of 12: S = 4, M = 6, L = 12. Two Ms share a row; three Ss share a row; an L is a full-width banner. Defaults: M for charts and lists, S for hosted tiles. Ignored in column layout.
paletteone of 13 palette idsallDefault ember. See palettes.
theme"light" · "dark"allThe tile’s card. Defaults to the canvas theme’s card default.
titlestringallThe heading on the card. Charts derive one when it’s missing ("Count by EventType", "sum of Amount by Region"); lists must supply one; hosted tiles use it only for the Model Summary heading.
filterobjectallNarrows the records this one tile reports on, before anything is aggregated. See Filters.
autoGrowtrueallSize the card to its content instead of the fixed height so nothing scrolls inside it. Write it only when true.

Every tile is 300 px tall. That’s the framed card height in both layouts, and it’s the single most important constraint on design: charts must read at 300 px, so few categories and short labels win. autoGrow lifts the cap (with a 240 px floor) and is most useful on a list, where it lets every row show.

Chart tiles

A chart tile is chartType plus the fields that chart’s family reads. Set only what the family needs; the rest is ignored.

{ "chartType": "groupedBar", "sourceId": "context",
  "dimension": "Category", "seriesField": "Region", "measure": "Amount", "aggregation": "avg",
  "size": "M", "palette": "tableau", "theme": "light", "orientation": "left",
  "title": "Average amount by category and region" }
FieldValuesRead byNotes
chartTypeone of 36 ids—Unknown values load as bar. Full list and per-chart guidance in the Report Chart Reference.
dimensioncolumn namemost familiesThe group-by / category axis / slice label. For the time, calendar and candlestick charts it’s the date column. For a sankey it’s the fallback first stage. Not used by scatter, bubble, histogram or gantt.
measurecolumn name or ""most familiesThe numeric column to roll up. Ignored when aggregation is count — write "". Required (raw values, no aggregation) by boxplot, histogram and candlestick.
aggregation"count" (default) · "sum" · "avg"aggregating familiesHow measure is rolled up per group. Not used by scatter/bubble, boxplot, histogram, candlestick or gantt.
seriesFieldcolumn nameseries, matrix, sunburst, xy, ganttThe second dimension: the series split of a grouped/stacked bar or radar, the rows (Y) of a heatmap, the outer ring of a sunburst, the colour split of scatter/bubble/gantt.
stageFields["A","B","C"]sankey, network, flowOrdered flow stages, at least two. Links aggregate between every consecutive pair, so a sankey is multi-column, not just from→to. Falls back to [dimension, seriesField] when absent.
xMeasure, yMeasurecolumn namesscatter, bubbleTwo genuinely numeric, varying columns.
sizeMeasurecolumn namebubbleBubble area.
granularity"day" · "week" · "month" (default) · "quarter" · "year"timeSeries, timeBar, cumulative, burndownThe calendar period the date axis is bucketed into. The calendar chart is always daily.
startField, endFielddate column namesganttThe span each bar covers.
labelFieldcolumn nameganttNames each bar; rows fall back to #1, #2, …
orientation"left" (default) · "right"charts with a side legend or label listWhich side the legend sits on.
showLegendfalsecharts that draw a legendHides it. Only false is meaningful; omit for shown. Axis charts fall back to labelling the axis.

Column names must match the data exactly, including case. A dimension that doesn’t resolve to a real column self-heals to the first low-cardinality text column once the data loads; a missing X/Y measure picks the first numeric non-key columns. That keeps a tile from rendering blank, but it isn’t your design — set the fields.

List tiles

A list of records, not a summary of them: one row per record, printing the chosen columns in order, separated by |, each column in its own palette colour so it reads down the list. Rows link to their record.

{ "kind": "list", "title": "Open tasks", "sourceId": "context",
  "fields": ["Name", "AssignedTo", "DueDate"], "groupBy": "Status", "maxItems": 10, "navigate": true,
  "size": "M", "palette": "slate", "theme": "light", "autoGrow": true }
FieldValuesNotes
titlestringRequired in practice — a list has nothing to derive one from.
fields["Col", …]Columns printed per row, in this order. Duplicates are dropped. Empty cells are skipped rather than printing stray separators. Left empty, the tile picks the first three non-key columns.
groupBycolumn nameSplits the list into sections, one heading per distinct value, headings sorted A→Z. Empty values group under (none). Omit for a flat list.
maxItems1–50, default 8Row cap. Records are taken in the order the data arrives, up to the cap, then grouped — so a low cap with groupBy can leave some groups empty. A report shows a readable extract, not the whole result set.
navigatetrue (default) · falseMake each row a link to its record. Rows whose record can’t be resolved simply render unlinked.

Palette colours are chart fills, so a pale one can be unreadable as text on a white card. The list checks contrast and gives any failing colour a small backing chip rather than recolouring it, so a column keeps the same colour it has in the charts.

Hosted Hubstream tiles

Some of the platform’s own tiles can sit in a report, bound to the report’s records instead of a server query. They are the real tiles, so they behave as they do anywhere else. Default size is S; they read best small.

{ "kind": "hubtile", "tile": "countdown", "sourceId": "context",
  "measure": "", "aggregation": "count",
  "dateField": "Deadline", "countdownMode": "countdown", "label": "Next deadline",
  "size": "S", "palette": "ember", "theme": "light" }
tileShowsReads
numberLabelOne big rolled-up figure with a captionmeasure + aggregation (count of records, or sum/avg of measure); label is the caption; palette colours the figure.
modelSummaryThe record count plus a field breakdowntitle as its heading; measure + aggregation as the first line; then up to maxItems (1–25, default 5) columns as “N distinct”.
countdownA live timer to or from a datedateField; countdownMode "countdown" (default, counts to the next upcoming date in the column) or "elapsed" (counts up from the most recent past one); label is the caption.
marqueeScrolling texttextField; the first maxItems non-empty values scroll; label becomes a static title. Prefer a text column — a marquee of numbers reads as noise.

measure and aggregation are always present on a hosted tile (write "measure": "", "aggregation": "count" when they don’t apply). urlField is accepted and reserved; nothing reads it yet. A filter works on every hosted tile, which is how you get “total amount, open cases” as a single number.

Filters

Any grid tile can carry one condition that narrows the records it reports on, applied before aggregation and without touching the page’s own query. One condition, deliberately — it’s a report-authoring convenience, not a query builder. The tile shows a line under its title so a filtered chart can’t be mistaken for the whole picture: Filtered by Status is "Open".

"filter": { "field": "Status", "operator": "equals", "value": "Open" }
operatorReads asNeeds value
contains (default)containsyes
notContainsdoes not containyes
equalsisyes
notEqualsis notyes
startsWithstarts withyes
gtis greater thanyes
ltis less thanyes
isEmptyis emptyno
isNotEmptyis not emptyno

Matching is case-insensitive throughout. gt/lt compare numerically when both sides parse as numbers, otherwise as strings — which means they work on ISO-style dates ("2026-03-01") but not on 12/03/2026-style ones. A filter with no field, or a value-taking operator with an empty value, is ignored rather than filtering everything out.

Annotations

An overlay of stickers and notes floating above the tiles, positioned so they stay put across screen widths: xPct is a percentage of the content width, yPx is pixels from the top of the report content. They’re saved in the definition and exported with the PDF — see Annotations.

{ "id": "anno-1", "type": "sticker", "xPct": 88, "yPx": 40, "sticker": "🔥" },
{ "id": "anno-2", "type": "sticker", "xPct": 12, "yPx": 400, "image": "https://stickers.hubstream.one/api/public/stickers/<id>/image" },
{ "id": "anno-3", "type": "notecard", "xPct": 55, "yPx": 360, "text": "Northern region audit.", "color": "#bce7ff" },
{ "id": "anno-4", "type": "comment", "xPct": 30, "yPx": 700, "author": "J. Milan", "text": "Check the March dip." }
FieldValuesNotes
idstringUnique; missing ids are generated.
type"notecard" (default) · "sticker" · "comment"The comment button is currently hidden from the toolbar, but the type still renders when it’s in the JSON.
xPct0–100, default 40Horizontal position.
yPx≥ 0, default 80Vertical position. A 300 px tile plus grid gap is a useful unit: row two starts around y ≈ 330 below the title block.
textstringNotecard / comment body.
authorstringComment attribution; the avatar shows initials.
stickeran emojiEmoji sticker.
imageURLImage sticker from the sticker service. Wins over sticker when both are set.
colorCSS colourNotecard background. The picker offers #fff7a8 (default), #bce7ff, #c7f5cf, #ffd6d6, #111827, #1e3a8a; text flips to white on the dark two.

If you’re drafting without seeing the rendered page, default to "annotations": []. Blind placement rarely lands where you meant.

How the loader treats what you write

The parser normalises every field against its allowed set and falls back rather than failing. Knowing the fallbacks tells you what a wrong value silently becomes:

You wroteIt loads as
unknown chartTypebar
unknown theme (canvas)white
unknown tile themethe canvas theme’s card default
unknown paletteember
unknown sizeM (charts, lists) / S (hosted)
unknown aggregationcount
unknown granularitymonth
unknown kindchart
unknown tile (hosted)numberLabel
unknown filter operatorcontains
sourceId other than "context""context"
maxItems out of rangeclamped to 1–50 (list) / 1–25 (hosted)
show, navigate missingtrue
non-string where a string is expected""

Optional string fields are written back only when non-empty, and showLegend / autoGrow only when they change the default, so a definition copied back out of the JSON view is a little leaner than what you pasted in. That’s expected.

Preview against real data

Don’t write a definition blind. The view has a built-in loop:

  1. Navigate to the records the report is for — a group of cases, the tasks under a case — and switch the view to Report. It auto-draws a starting report from the data.
  2. Click JSON in the toolbar. You’ll see the auto-built definition. Its column names are real, so it’s a handy way to read the exact spellings.
  3. Paste your draft and press Apply. The report renders immediately. A tile that says No data in context has data but nothing in the fields you named; one that came out as a bar chart had an unknown chartType.
  4. Iterate, then Copy. Applying marks the report as authored: it’s saved in the browser for lists with this set of columns, and the auto-builder stops regenerating over it. Reset throws that away and rebuilds from the data.
  5. Save as a Space to keep it, or Save to PDF to export.

Checklist before you ship one

  • JSON parses (Apply is the test).
  • version: 1, layout is grid or column, theme is one of the nine ids (not light/dark).
  • Every sourceId is "context".
  • Every column name — dimension, measure, seriesField, stageFields, xMeasure/yMeasure/sizeMeasure, startField/endField/labelField, list fields, groupBy, filter field, dateField, textField — is spelled exactly as the data has it.
  • count tiles have measure: ""; sum/avg tiles name a real numeric column.
  • Each chart has the fields its family needs (see the reference); nothing else.
  • Filters name a field and, for value operators, a non-empty value.
  • title.title names the subject; title.body is short HTML with real facts, not placeholder text.
  • annotations is present — [] unless you can see where they land.