build-report

Turn a pile of findings into a report someone reads — what to cut, which format fits where it is read, and how to get it delivered.

# Turning aggregated findings into a report people read

Collecting the data is the easy half. A report nobody reads is worth less than

no report at all — it teaches whoever assigned it that they don't need to

check the next one either. What follows is what decides whether the second

one gets read.

Before writing anything: what actually changed

The single biggest failure mode of a recurring report is repeating what's

stable and burying what's new inside it. Before drafting:

changed, say "unchanged" in one line — do not re-describe it at the same

length as something new.

reader skims top to bottom once; the first three lines are the only ones

guaranteed to be read.

trains the reader to skim past all of them, including the ones that

matter.

Pick the format for where it's read, not for what looks impressive

Three shapes, in order of how often each is actually the right one:

1. A chat message. Five to seven lines, plain text, one clear point per

line. Correct for anything read on a phone between other things — which

is most recurring reports. Do not reach for HTML because it exists.

2. A Markdown document. For something that needs headings, a table, or

links, and will be opened deliberately rather than glanced at — a weekly

roundup, a comparison. Still short: a table beats three paragraphs

describing the same numbers.

3. A self-contained HTML page. Only when something genuinely benefits

from layout — several data series, or something meant to be shared as a

link. Single file, inline CSS, no external requests: a page that depends

on a CDN being reachable is a page that's sometimes blank. Template

below.

Reaching for the fanciest format by default is the second most common

failure, right after skipping the diff.

Every claim needs a source, in the report itself

Not "sources available on request" — the specific link or file sits next to

the specific line. A number with nowhere it came from is not a finding, it's

a guess wearing a finding's clothes. If a source can't be reached, say the

section is thin rather than filling it with something plausible-sounding.

Separate what happened from what you make of it

The two are different kinds of claim and erode trust differently if mixed:

"the API raised its price 40%" is a fact; "they're betting nobody

re-benchmarks" is a read on it. Label the second explicitly — a reader who

can't tell which is which stops trusting either.

A self-contained HTML dashboard, minimal and dependency-free

`html

<!doctype html>

<html lang="en">

<head>

<meta charset="utf-8">

<title>{{ report title }}</title>

<style>

:root { color-scheme: light dark; }

body { font: 15px/1.5 -apple-system, system-ui, sans-serif; max-width: 720px;

margin: 2rem auto; padding: 0 1rem; }

h1 { font-size: 1.3rem; margin-bottom: .25rem; }

.meta { color: #888; font-size: .85rem; margin-bottom: 1.5rem; }

.stat-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }

.stat { border: 1px solid #8883; border-radius: 8px; padding: .75rem 1rem; }

.stat b { display: block; font-size: 1.4rem; }

.stat span { font-size: .8rem; color: #888; }

.item { border-top: 1px solid #8883; padding: .75rem 0; }

.item .src { font-size: .8rem; color: #888; }

.unchanged { opacity: .55; font-size: .85rem; }

</style>

</head>

<body>

<h1>{{ report title }}</h1>

<p class="meta">{{ date }} · covers {{ window, e.g. "last 24h" }}</p>

<div class="stat-row">

<div class="stat"><b>{{ number }}</b><span>{{ what it counts }}</span></div>

</div>

<div class="item">

<div>{{ what happened, one line }}</div>

<div class="src"><a href="{{ url }}">{{ source name }}</a></div>

</div>

<p class="unchanged">{{ what stayed the same, one line, not expanded }}</p>

</body>

</html>

`

No JS, no charting library, no font import — each is something that can fail

silently when the page is opened somewhere without internet, and a dashboard

nobody can open is worse than the chat message it replaced.

What goes wrong

The report grows every cycle and gets read less each time. Almost always

means the diff-against-last-time step was skipped — stable sections

re-described in full instead of collapsed to "unchanged."

A number with no source next to it. The most common way a report stops

being trusted. If you can't find where a number came from, cut the number

rather than keep it unsourced.

A stat with no unit, or a chart with no axis label. Obvious in the source

data, meaningless on the page once separated from it. If a number needs the

surrounding sentence to mean anything, put the unit on the number itself.

Opinion presented as if it were the finding. "X is concerning" is not a

finding; "X changed by this much, for this reason we could confirm" is. Say

which one is being given.

Getting it to the reader

A report that exists only inside the agent's session was never delivered. How

this works is engine-specific; the pattern below is Hermes, checked on a

running pod rather than read from documentation.

See what you can actually deliver to, first.

`

hermes send --list

`

If it answers "no messaging platforms configured", nothing can be delivered

yet — a channel has to be connected before any of the rest of this matters.

Checking first turns a silent non-delivery into a clear one.

Send the report as the message.

`

hermes send --to telegram --file /opt/data/workspace/report.md

hermes send --to discord:#ops --subject "[weekly]" --file report.md

`

Targets are platform, platform:chat_id, or platform:#channel-name. For

bot-token platforms this needs neither a model nor a running gateway, so it

works from a cron job — which is what a recurring report usually is.

Send a file as an attachment.

`

hermes send --to telegram "MEDIA:/opt/data/workspace/dashboard.html"

`

--file reads the message *text* from a path; MEDIA:<path> in the message

attaches the file itself. Mixing these two up sends the raw HTML of your

dashboard into the chat as a wall of text.

Keep the file on the persistent disk, not in /tmp. /opt/data survives

restarts, and the previous report is what the next one diffs against — the

first section of this skill is impossible without somewhere to keep it.

One thing to know before promising a link: on a pod like this there is no

public URL for a file you write. The only HTTP surface is the dashboard, and

it sits behind authentication. A report that must be shareable as a link needs

somewhere else to host it; an attachment is what actually works out of the box.

What this skill doesn't cover

The delivery commands above are Hermes. Other engines have their own way, and

this file does not attempt to guess at them — an invented command is worse

than an absent one. Find the equivalent of send --list for whatever you are

running before designing around delivery.

Scheduling — how the report gets triggered daily or weekly — is also outside

this file.

One trap worth naming even though it belongs to scheduling: a report headed

"today" is written in the pod's timezone, which is often not the reader's. A

daily digest that arrives at 07:00 for the agent and 23:00 the previous day

for the person reading it is wrong in a way nobody reports as a bug — they

just stop trusting the dates. Set the pod's timezone to the reader's, or put

the offset in the report itself.