Skip to content

Structured Reports

The agent’s operator-facing reports follow a single template so an operator or a peer agent can read any of them at a glance and know what is observed fact, what is interpretation, and what action (if any) is suggested.

  • A structured report is a deterministic snapshot of one slice of the system (disk, services, tasks, budget, publish state, build/test status).
  • Reports are built from raw inputs — DB rows, command output, JSON status files — by a pure builder function. The builder has no side effects and is unit-tested independently of how the report is delivered.
  • The result is rendered to HTML for Telegram and could equally be rendered to JSON for a dashboard or to plain text for a CLI.
  • When the agent answers an ops question, it reads the structured report rather than narrating from memory. This matters because memory drifts; ZFS pool capacity does not.

Every structured report has the same three top-level sections:

What the report measured, with no interpretation. ZFS shows pool A at 87% capacity. Build status file says status: "fail". The last task in the queue was created at 10:23.

This section is the source of truth for the rest of the report. If Observed is empty, the underlying probe failed and the report says so.

A handful of findings extracted from Observed, each tagged info, warn, or error. “Pool A is at 87% capacity.” “Tests last run failed. 12 failing tests.” “No active controlplane tasks are queued right now.”

Findings are short, factual, and avoid recommending action. Their job is to reduce a wall of data to the few signals that matter.

Suggestions, conditional and labeled note or action. “Largest snapshot: tank/data@2026-04-20-weekly (4.2 GB). Remove only if that rollback point is no longer needed.” “Re-run the test wrapper before relying on this as evidence the branch is green.”

Notes are suggestions, not commands. They include the conditional that makes the action correct (“only if X”), so an operator can decide without re-deriving the context.

ReportModuleSlash commandSource
Systemsrc/reports/system-report.ts/reporthostd probes + controlplane auth probe
Disksrc/reports/disk-report.ts/diskzpool list -H + zfs list -H -o name,usedsnap
Taskssrc/reports/tasks-report.ts/tasksgetAllTasks() (Postgres)
Budgetsrc/reports/budget-report.ts/budgetreportgetAllBudgets() + getAgentTokenAnalytics()
Publishsrc/reports/publish-report.ts/publishreporttenant registry + webroot inspection
Test/Buildsrc/reports/test-report.ts/testreporttmp/status/build-status.json + test-status.json

Each module exports two functions:

buildXxxReport(inputs) // pure: takes raw inputs, returns a typed report
renderXxxReport(report) // pure: takes the report, returns an HTML string

The split lets you unit-test the analysis without touching IO and reuse the builder against a JSON sink later.

For publish-state reporting specifically, the source of truth is not only the registry. The report reads live served output from the CMS webroot, and the setup verify step now also checks publish-manifest consistency for tenant sites. That keeps “declared”, “served”, and “last published” closer together.

/testreport is the only report whose source-of-truth is a file the agent does not write itself. The contract:

  1. scripts/write-test-build-status.sh runs npm run build and npx vitest run --reporter=json --outputFile=... (or one of them via build / tests argument).

  2. The wrapper writes two JSON files into the status directory:

    • <status-dir>/build-status.json
    • <status-dir>/test-status.json

    The status directory resolves with this precedence (matched by both the wrapper and getDefaultStatusDir() in src/reports/test-report.ts):

    1. $AGENT_STATUS_DIR if set
    2. $CLAWDIE_VAR_DIR if set (legacy)
    3. <project-root>/tmp/status (default)

    Per AGENTS.md § “Temporary File Storage”, artifact paths under repo tmp/ are the preferred default — point $AGENT_STATUS_DIR elsewhere only if you have a reason to.

  3. /testreport reads both files, builds the report, renders it.

The schema for each file is intentionally narrow:

{
"status": "ok" | "fail" | "unknown",
"completedAt": "2026-04-26T10:00:00Z",
"command": "npx vitest run",
"exitCode": 0,
"durationMs": 12345,
"totalTests": 1934,
"failingTests": 0,
"skippedTests": 0,
"failingTestNames": ["..."],
"summary": "..."
}

Only status and completedAt are required; everything else degrades gracefully. Files older than 6 hours surface as stale with a warn finding. Missing or malformed files surface as status: "unknown" with an action note rather than fabricating success.

The pre-commit and post-commit hooks call this wrapper so commit messages include a Build: pass | Tests: 12 failed | 1936 passed (1948) footer visible in git log.

When the agent receives a bot-addressed message, classifyReportIntent() in src/report-intent.ts checks a set of conservative regexes and routes to the matching structured report instead of the LLM path. This means an operator typing “how much disk?” gets a fresh /disk snapshot, not a half-remembered narrative from a session three days ago.

The routing rules are intentionally narrow (false negatives are fine, false positives are not). For broader detection of “this prompt smells operational”, a separate isOpsFlavored() matcher catches a wider net of phrasings (services, jails, deploy, controlplane terms, etc.) — and is used to suppress memory injection on those prompts so the LLM answers from live tools rather than narrative recall.

FunctionUse
classifyReportIntent(text)Hard route → structured report. Only fires on confident phrasings.
isOpsFlavored(text)Soft signal → drop memory injection. Wider net, lower bar.

Both ignore slash-command messages (those are routed by grammy) and @assistant mentions are stripped before matching.

The pure builder pattern was a deliberate choice over a one-shot “render-to-HTML now” approach. Three reasons:

  • Testable — unit tests exercise the analysis logic with synthetic inputs, no Postgres or pi running.
  • Reusable — the same buildDiskReport() could feed a dashboard widget or a daily email digest later. We are not committed to Telegram as the only sink.
  • Inspectable — when an operator asks “why did the report flag this?”, the answer is a findings[] array with explicit codes, not opaque text generation.

If you add a new report, follow the same shape: a Report interface, a buildXxxReport() function with findings: XxxReportFinding[] and operatorNotes: XxxReportOperatorNote[], a renderXxxReport() HTML renderer, and a *.test.ts covering the builder independently.