Shared reports
A report is a Markdown file with charts in it. The prose is analysis; each chart is a widget whose rows are either frozen in the file or produced by a live SQL query the server re-runs on a schedule. An agent writes the file and publishes it with the ah CLI; everyone in the organization reads it at /reports/<slug> in the dashboard, where live widgets stay current and every revision is kept.
Agents write, people read
There is no editor. The authoring file is the source of truth; the dashboard renders it. Ask your agent for the report — the agenthog-report skill teaches it the loop below.
Live where it matters
A live widget is a constrained SELECT with a TTL. It recomputes when someone looks and the cache has expired — never while nobody is watching — and the card says when it was computed.
Revisions, not overwrites
Every update adds a revision. The page shows the latest; ?rev=N and ah reports history show the rest. Nothing is ever lost.
The loop
Five commands, in this order. preview is the one that keeps mistakes out of the org’s view: it validates the file, runs every live widget once, prints the resolved report with its hints, and saves nothing.
ah reports read last-week # learn the house format from an existing report # write weekly.md (below) ah reports preview --file weekly.md # validate + run every widget; fix what the errors name ah reports create weekly-growth --file weekly.md # publish — visible to the whole org now ah reports read weekly-growth # verify from the published copy; post the URL
Next week: ah reports pull weekly-growth --out weekly.md, revise the prose, then ah reports update weekly-growth --file weekly.md. Widgets whose spec did not change keep their cache. list, read, preview, pull, history, diff, show, refresh and widgets work with a read token; create, update and delete need write scope.
The authoring file
Ordinary GitHub-flavored Markdown — headings, lists, tables, task lists, footnotes and link references all render (raw HTML is dropped) — with an optional front matter block and any number of top-level ```widget fences. Each fence holds one JSON object: a widget kind plus either rows (frozen) or sql (live).
---
title: Weekly growth
description: Where the funnel stands this week. Kept current by the growth agent.
project: ah_xxxxxxxx # default project for widgets that name none
since: 7d # default window for live widgets (default 7d)
---
## Where we are
Signups are up 12% week over week (as of Aug 30), carried by the pricing page rewrite.
The live numbers below recompute on their own — the line under each one says when.
```widget
{ "widget": "stat", "title": "This week", "ttl": "1h",
"sql": "SELECT count(DISTINCT s.anon_id) AS visitors, count(*) FILTER (WHERE e.name = 'signup') AS signups FROM events e JOIN sessions s ON s.id = e.session_id WHERE e.ts >= :since AND s.classification NOT IN ('crawler','suspected_bot','test')" }
```
```widget
{ "widget": "line", "title": "Signups per day", "ttl": "1h", "since": "30d",
"sql": "SELECT date_trunc('day', e.ts)::date AS day, count(*) AS signups FROM events e JOIN sessions s ON s.id = e.session_id WHERE e.name = 'signup' AND e.ts >= :since AND s.classification NOT IN ('crawler','suspected_bot','test') GROUP BY 1 ORDER BY 1" }
```
```widget
{ "widget": "bar", "title": "Sessions by source", "width": "half", "ttl": "6h",
"sql": "SELECT coalesce(utm_source, '(direct)') AS source, count(*) AS sessions FROM sessions s WHERE started_at >= :since AND s.classification NOT IN ('crawler','suspected_bot','test') GROUP BY 1 ORDER BY 2 DESC LIMIT 12" }
```
```widget
{ "widget": "table", "title": "Top events", "width": "half", "ttl": "1h",
"sql": "SELECT e.name, count(*) AS n FROM events e JOIN sessions s ON s.id = e.session_id WHERE e.ts >= :since AND s.classification NOT IN ('crawler','suspected_bot','test') GROUP BY 1 ORDER BY 2 DESC LIMIT 20" }
```
## Against plan
```widget
{ "widget": "stat", "title": "Q3 targets",
"rows": [ { "signups": 600, "conversion": 0.042 }, { "signups": 480, "conversion": 0.038 } ],
"format": { "conversion": "percent" }, "labels": { "signups": "Q3 signups" } }
```
Next: turn on the retention email once the [experiment](/flags) reads.
| Front matter | Meaning |
|---|---|
title | The report title. Required — or pass --title on the command line. |
description | One line under the title in the list and on the page. |
project | The project key live widgets query when they name none. A widget may set its own project or a projects list; one report can draw on several. |
since | The default window for live widgets (default 7d). Same forms as ah sql: 30m, 24h, 7d, YYYY-MM-DD, ISO. |
Slugs are lowercase letters, digits and hyphens, up to 64 characters; create derives one from the title when you omit it. Reserved: preview, widgets, new, history, latest.
The widget library
Five kinds. Each has a column contract: the shape the rows — inline or from the query — must have. The parser checks inline rows when you publish; live rows are checked on every compute, and a query whose shape drifts fails that widget with the contract named, never the page. ah reports widgets prints this table.
| Widget | Draws | Column contract | Hints |
|---|---|---|---|
stat | a row of KPI tiles | exactly ONE row; every column is a tile (label = column name or labels[col]). An optional second row is the comparison → delta badge. | format · labels |
line | a time series (or any ordered x) | an x column (default: the first column; timestamp or text) plus one or more numeric columns as series (all of them, or the ones named in series[]). | x · series · format · labels |
area | a stacked or plain area chart | as line, plus stack: true to stack the series. | x · series · stack · format · labels |
bar | categorical or time-bucketed bars | as line, plus stack. Bars turn horizontal when x is text and there are more than 8 categories. | x · series · stack · format · labels |
table | a sortable table | any columns; up to 2,000 rows are kept (rowCount says how many there were). | format · labels |
stat — a row of KPI tiles
```widget
{ "widget": "stat", "title": "Last 7 days", "ttl": "1h",
"sql": "SELECT count(*) AS sessions, count(DISTINCT anon_id) AS visitors, round(avg(EXTRACT(EPOCH FROM (last_at - started_at)))) AS avg_duration FROM sessions s WHERE started_at >= :since AND s.classification NOT IN ('crawler','suspected_bot','test')",
"format": { "avg_duration": "duration" }, "labels": { "avg_duration": "Avg session" } }
```line — a time series (or any ordered x)
```widget
{ "widget": "line", "title": "Sessions per day", "since": "30d", "ttl": "1h",
"sql": "SELECT date_trunc('day', started_at)::date AS day, count(*) AS sessions, count(*) FILTER (WHERE converted) AS converted FROM sessions s WHERE started_at >= :since AND s.classification NOT IN ('crawler','suspected_bot','test') GROUP BY 1 ORDER BY 1" }
```area — a stacked or plain area chart
```widget
{ "widget": "area", "title": "Sessions by device", "stack": true, "ttl": "6h",
"sql": "SELECT date_trunc('day', started_at)::date AS day, count(*) FILTER (WHERE device_type = 'mobile') AS mobile, count(*) FILTER (WHERE device_type = 'desktop') AS desktop FROM sessions s WHERE started_at >= :since AND s.classification NOT IN ('crawler','suspected_bot','test') GROUP BY 1 ORDER BY 1" }
```bar — categorical or time-bucketed bars
```widget
{ "widget": "bar", "title": "Sessions by source", "width": "half", "ttl": "6h",
"sql": "SELECT coalesce(utm_source, '(direct)') AS source, count(*) AS sessions FROM sessions s WHERE started_at >= :since AND s.classification NOT IN ('crawler','suspected_bot','test') GROUP BY 1 ORDER BY 2 DESC LIMIT 12" }
```table — a sortable table
```widget
{ "widget": "table", "title": "Top events", "width": "half", "ttl": "1h",
"sql": "SELECT e.name, count(*) AS n, count(DISTINCT s.anon_id) AS visitors FROM events e JOIN sessions s ON s.id = e.session_id WHERE e.ts >= :since AND s.classification NOT IN ('crawler','suspected_bot','test') GROUP BY 1 ORDER BY 2 DESC LIMIT 20" }
```Frozen rows — the same library with the data in the file. A second stat row is the comparison and draws a delta badge:
```widget
{ "widget": "stat", "title": "Q3 targets",
"rows": [ { "signups": 600, "conversion": 0.042 }, { "signups": 480, "conversion": 0.038 } ],
"format": { "conversion": "percent" }, "labels": { "signups": "Q3 signups" } }
```| Key | Applies to | Meaning |
|---|---|---|
title | all | The card title. Untitled cards render as (untitled) — name them. |
note | all | A line of small text under the title. |
width | all | "half" lays two widgets side by side on wide screens; everything stacks on narrow ones. |
x | line area bar | The x column (default: the first column). A timestamp or date gives a time axis; text gives categories. |
series | line area bar | Which numeric columns to draw (default: every numeric column besides x). |
stack | area bar | true stacks the series. |
format | all | Column → one of number percent currency duration seconds text. Percent takes a fraction (0.042 → 4.2%) — a column whose values all lie in −1…1 is read as a ratio, anything else as percent points already (41 → 41.0%); duration takes seconds. |
labels | all | Column → display label. Otherwise the column name is the label, so name columns for readers. |
project / projects | live | The project key(s) the query runs against; default: the front matter's project. |
since / until | live | The window bound to :since / :until. Relative specs resolve when the widget computes. |
ttl | live | Cache time, 1m–30d, default 1h. |
Live queries
A live widget’s sql runs under the same guard as ah sql: one read-only SELECT (or WITH), scoped to the report’s projects, 10-second timeout, the tables ah schema lists. :since and :until are bound to the widget’s window; because a relative since is resolved at compute time, "7d" always means the last seven days — that is what makes the widget live. A literal date inside the SQL is not.
Exclude bots and test traffic yourself. Every other ah verb applies the default filter for you; a live query sees raw rows. Unless the report is about bots, join sessions and filter on its classification — from events:
SELECT count(*) AS signups
FROM events e
JOIN sessions s ON s.id = e.session_id
WHERE e.name = 'signup' AND e.ts >= :since
AND s.classification NOT IN ('crawler','suspected_bot','test')From sessions, the same WHERE on classification without the join. preview prints a hint whenever a query reads events or sessions without mentioning classification — a hint, never a rejection, because some reports are about bots.
Results are capped at 2,000 rows (the card says how many there were) and 256 KB; aggregate or LIMIT rather than shipping raw rows. A failed compute — a timeout, a column that disappeared, a project moved to another organization — leaves the last good rows on the card, dated by their compute, with the error text underneath; ah reports read prints the same text under the widget for the agent, and ah reports show lists every widget’s cache state.
Freshness
Pick the TTL by how the reader watches, not by how fresh the data could be: a weekly review 24h; something people glance at during the working day 1h (the default); liveops during a launch 5m–15m. Floor 1m, ceiling 30d.
Opening a report serves whatever is cached, immediately. A widget past its TTL is shown as it was — marked stale — refreshing — while one recompute runs in the background, and the page picks up the result on its own. A widget with no cache yet computes inline (create warms every widget so the first click is instant). The refresh button on a card, ah reports refresh, or POST /api/v1/reports/:slug/refresh recompute now, bounded to once per widget per 60 seconds. A report holds at most 24 live widgets; freeze the rest.
Revisions
create makes revision 1; every update appends one. The page renders the latest; the history link lists them all and ?rev=N shows an older one behind a banner (its live widgets show current data — history keeps the prose and the specs, not the numbers). From the CLI:
ah reports history weekly-growth # rev, when, by, blocks ± ah reports diff weekly-growth --from 3 --to 4 # unified diff of the two sources ah reports read weekly-growth --rev 3 # an older revision, resolved ah reports pull weekly-growth --rev 3 --out weekly-r3.md # its file, byte-identical
ah reports delete <slug> --yes removes the report with every revision; the flag is mandatory because agents are non-interactive.
Reading a report
In the dashboard, Reports sits in the rail’s account section: reports belong to the organization, not to one project, so the project switcher does not filter them. Every member sees every report from the moment it is created — there is no draft state and no per-report permission. Sharing outside the organization is not available yet; Print on the page gives a clean sheet with each widget’s as-of line, which is the export for now.
From the terminal, ah reports read <slug> prints the same resolved report — prose, every widget’s current rows, and the as-of/TTL footer under each — which is how an agent verifies what it published. Long tables are cut to 40 rows (--full for all); --json returns the resolved blocks with rows for scripting.
# Weekly growth Where the funnel stands this week. Kept current by the growth agent. (revision 4 · 2026-08-31 10:09 by growth-agent · rendered 2026-08-31 10:28) ## Where we are … ▸ This week [stat · live] visitors 1,204 · signups 37 as of 2026-08-31 10:09 (19m ago) · refreshes every 1h · window 7d (2026-08-24 → now) · 12 ms
Limits
| What | Cap |
|---|---|
| Live widgets per report | 24 |
| Blocks per report (prose sections + widgets) | 100 |
| Rows kept per live result | 2,000 (rowCount records the true count) |
| Inline rows per frozen widget | 5,000 |
| Result size | 256 KB |
| SQL per widget | 8 KB · 10s |
| Authoring file | 512 KB |
| TTL | 1m – 30d, default 1h |
| Manual refresh | once per widget per 60s |
For agents and scripts
The skill is the fastest way in: it teaches an agent the loop above, the classification idiom, TTL choice and how to verify — install it from the skills page or take the raw file:
mkdir -p ~/.claude/skills/agenthog-report && \
curl -fsSL https://agenthog.io/skill/report/SKILL.md \
-o ~/.claude/skills/agenthog-report/SKILL.mdEvery verb has an HTTP twin under /api/v1/reports with the token auth described in Server to server: GET /api/v1/reports, POST /api/v1/reports { slug?, source, title?, warm? }, GET /api/v1/reports/:slug/render?rev=N&wait=1 (the resolved blocks with rows), PATCH and DELETE /api/v1/reports/:slug, GET …/revisions, POST …/refresh, POST /api/v1/reports/preview and GET /api/v1/reports/widgets. The file is the only write payload — no blocks JSON — so pull is byte-identical for free.
See also the ah CLI for ah sql and ah schema, which a live query is built on.