Write, publish and keep current a shared AgentHog report โ a Markdown file with live charts that everyone in the organization opens in the dashboard...
A report is one Markdown file. Prose is your analysis; every top-level ```widget
fence is a chart or table โ either frozen rows you supply, or a live SQL query the
server re-runs within its TTL. Publishing it puts it at https://agenthog.io/reports/<slug>
for every member of the organization, at once (there is no draft state). Humans read there;
you write and revise it with the ah CLI. Every update is a new revision โ nothing is ever
overwritten.
Follow the phases in order. Each ends with a check โ do not proceed past a failed check.
ah is installed and authenticated: ah whoami shows the account line โ that is the
organization the report will belong to. Drafting and preview work with read scope;
create, update, delete need write scope (an org admin mints it at
https://agenthog.io/tokens, or ah login --write). With several logins, every
ah reports command needs --account <org>.ah projects list prints the keys
(ah_xxxxxxxx). One report may draw on several.ah events top --since 7d for the event names, ah schema for the
tables and columns a live query can see. Never invent a column โ check.ah reports list # what the org already reads
ah reports read <slug> # the resolved report, exactly as a member sees it
ah reports pull <slug> --out prior.md # the authoring file behind it
Match the structure, tone, section order and widget choices of the most recent report on the
same subject; readers compare weeks. If the org has none yet, ah reports widgets prints the
widget library with each widget's column contract and a working example.
"rows": [...]) are for numbers that should stand still: the comparison
you computed, targets, a snapshot the analysis refers to. Frozen widgets cost nothing to
view."sql": "...") are for what the reader should see current. 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". A report holds at most 24 live widgets, and every live widget is a
query โ keep it to the numbers that matter.<!-- analysis ttl: 7d --> inside a section (a launch readout ages in days, a quarterly
note in months), <!-- analysis ttl: never --> on an appendix that is history on purpose,
or stale_after: in the front matter for the whole report. The comment is metadata โ no
renderer shows it, and editing it does not count as revising the paragraph beside it.---
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)
stale_after: 14d # mark a section stale once its prose is this old (optional)
---
## Where we are
<!-- analysis ttl: 7d -->
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.
Rules the parser enforces โ and the reasons behind them:
title (required, or pass --title), description, project,
since, stale_after. Anything else is ignored with a warning.preview warns.rows or sql. Keys: widget title note width rows sql project projects since until ttl x series stack format labels. format per column is one of number percent currency duration seconds text; labels renames columns; width: "half" puts two
widgets side by side, width: "third" three โ good for a dense KPI band of stats.stat: exactly one row; every column is a tile. An optional second row is the
comparison and draws a delta badge. Aggregate without GROUP BY.line / area / bar: an x column (default: the first column โ a timestamp, date or
text) plus one or more numeric columns as series (all of them, or the ones in
series). ORDER BY the x column. stack: true stacks. Bars go horizontal when x is
text with more than 8 categories.table: any columns; add a LIMIT โ up to 2,000 rows are kept.ah sql: one SELECT
(or WITH), 10-second timeout, the tables ah schema lists. :since and :until are the
widget's window: since comes from the widget, else the front matter, else 7d; until
only from the widget (default: now). Relative specs resolve at compute time, which is
what makes a widget live. A literal date in the SQL is not live. :until may only appear
when the widget sets until.events, JOIN sessions s ON s.id = e.session_id and
s.classification NOT IN ('crawler','suspected_bot','test'); from sessions, the same
WHERE. Live queries see raw rows โ the default filter the other ah verbs apply is not
applied for you. preview hints when a query touches events or sessions without it.count(*) AS signups, not count. Ratios as fractions
(0.042) with "format": {"col": "percent"}; money in major units with currency;
seconds with duration./flags).ah reports preview --file weekly.md # validates, runs every widget once, saves nothing
ah reports preview --file weekly.md --full # every row of every table
ah reports preview --file weekly.md --json # the resolved blocks, for checking numbers
Two kinds of error, in two places:
line 14: widget "stat": contract is exactly one row โฆ โ got 31 rows, line 20: unknown key "tittle" (did you mean "title"?). Fix and re-run until the file is accepted.โธ Title [kind ยท live] line as
โ error at <time>: widget "<kind>": โฆ โ the report still renders, that widget does not.Then read the output as the reader will:
as of โฆ ยท refreshes every โฆ footer, none shows a
โ error line;hint: about classification means a query is counting bots โ add the filter, or state
in the prose that bots are included on purpose;stat tile that reads 0 or โ usually means the wrong event name (ah events top).ah reports create weekly-growth --file weekly.md
It prints the URL and warms the cache so the first click is instant. The report is visible to the whole organization from this moment. Then verify from the published copy, which is exactly what a member sees:
ah reports read weekly-growth
Post https://agenthog.io/reports/weekly-growth with a one-line summary of the finding. Do not
paste the report into chat โ the page has the live numbers, chat would have stale ones.
On the agreed cadence, revise the same slug โ the history is the value:
ah reports pull weekly-growth --out weekly.md # the current authoring file (--force to overwrite a differing local copy)
# revise the prose; keep widgets whose spec is unchanged (their cache carries over)
ah reports update weekly-growth --file weekly.md
ah reports read weekly-growth # verify
ah reports read <slug> marks any section whose prose the numbers have outgrown
(โ STALE ยท analysis updated โฆ ยท data now runs to โฆ, with a count under the title). Start
a revision there: those are the paragraphs that are now lying to the reader. Rewriting the
prose clears the mark โ touching the widgets around it does not.ah reports history <slug> lists revisions; ah reports diff <slug> shows what the last
update changed; ah reports read <slug> --rev N reads an older one. Create a new slug only
when the reader wants separate per-period reports.ah reports read <slug> prints the full error text under
the widget; ah reports show <slug> lists every widget's cache state (as of, fresh/stale,
rows, the start of the last error) with its block id. Fix the query in the file and update.ah reports refresh <slug> [--block <id>] recomputes now (bounded to once per widget per
60 seconds); the block ids are in show.ah reports delete <slug> --yes drops every revision. Only when the user asks.| message | fix |
|---|---|
widget fence is not valid JSON |
strict JSON: double quotes, no trailing commas, no comments |
unknown key "tittle" (did you mean "title"?) |
the key list in ยง3 |
give exactly one of "rows" (inline data) or "sql" (live query) |
a widget is frozen or live, never both |
widget "stat": contract is exactly one row โฆ โ got N rows |
aggregate without GROUP BY, or use table |
contract is an x column plus one or more numeric series columns |
cast counts to numbers; name the series; x is the first column |
series column "x" is not numeric in every row |
a NULL/text value slipped in โ coalesce(โฆ, 0) |
sql references :until but the widget sets no "until" |
add "until" or drop :until |
only read-only SELECT / WITH queries are allowed |
one statement, no writes |
query exceeded the 10s time limit |
narrow the window, aggregate more, add LIMIT |
project ah_โฆ is not in this organization |
the key moved or is misspelt โ ah projects list |
widget names no project and the organization has N โ set \project:`` |
the org has several projects: set project: in the front matter or on the widget |
N live widgets โ the cap is 24 |
freeze the ones that need not move as inline rows |
a widget fence inside a list renders as a code block |
move the fence to the top level |
unknown analysis directive "โฆ" |
the only one is <!-- analysis ttl: 7d | never -->, alone on its line |
ah reports read <slug> shows the analysis with every live widget populated and no
error: linescrawler/suspected_bot/test sessions, or the prose says
bots are includedah reports read <slug> โ you either rewrote it or said
with <!-- analysis ttl: โฆ --> how long it should be trustedah reports history <slug>), and
ah reports diff <slug> shows only what you meant to changeFormat reference and examples: https://agenthog.io/docs/reports.