Share an interactive HTML report
You built a dashboard. It has a chart that filters when you click the
legend, a table that sorts, three tabs. It works perfectly in
file:///Users/you/report.html.
Then you try to give it to someone.
- Email strips the scripts, or the client refuses the attachment outright for having them.
- Slack shows a filename. The recipient downloads it, their browser warns them, they open it, and now there are two copies drifting apart.
- CI artifacts hand back a zip behind a login, on a retention clock.
- Confluence disabled the HTML macro years ago — most Cloud instances can't render one at all without an extra app.
- GitHub renders markdown in a job summary and
.ipynbas static output; a job summary strips scripts and iframes outright. - Jenkins serves HTML Publisher output under a
Content-Security-Policythat blocks inline script and style, which is why your report shows up as unstyled markup. - GitHub Pages works — and publishes it to the entire internet, unless you're on Enterprise Cloud.
So people screenshot the chart into Slack and the interactivity, which was the whole point, never leaves the laptop it was built on.
What Comma does instead
Comma stores report HTML verbatim — scripts, inline handlers,
embedded data and all — and renders it inside an iframe with
sandbox="allow-scripts" and no allow-same-origin.
That combination is the whole design. The frame gets an opaque origin:
your report's JavaScript executes normally, but it cannot read the app's
DOM, cookies, localStorage, or session — there is no same-origin
relationship to exploit. The sandbox is the security boundary, so the
HTML doesn't have to be scrubbed to be safe to host. An earlier version
of the API did strip <script> on ingest; it broke interactive reports
without adding anything the sandbox wasn't already covering, so it's
gone.
Practically: a Chart.js dashboard is a Chart.js dashboard at the link.
curl -fsS -X POST https://commareports.com/api/v1/reports \
-H "Authorization: Bearer $COMMA_API_TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile html dashboard.html \
'{title: "Q3 pipeline dashboard", html: $html}')"
The response carries the report id and its share URL. Or skip the curl and drag the file into the app — same result.
What works, and the one thing that doesn't
Works:
- Inline
<script>, inline event handlers, ES modules in the document. - Chart.js, Plotly, D3, ECharts, Vega-Lite — inlined or loaded from a CDN. Subresources fetched inside the sandboxed frame aren't governed by the app's CSP.
- Tabs, accordions, sortable tables, client-side filters,
<details>, CSS animation, responsive media queries. - Reports exported by tools that produce one self-contained file:
Lighthouse, pytest-html
--self-contained-html, ydata-profiling, Quarto withembed-resources: true, nbconvert,dbt docs generate --static.
Doesn't work: a report that fetches a sibling file at view time.
An opaque origin has no base URL, so fetch("data/results.json") has
nothing to resolve against. Allure is the classic
case — its UI is a single-page app that loads its results over XHR, which
is also why it's blank when you open it over file://. Two fixes:
- Inline the data. Most generators have a self-contained mode. It's one flag and it makes the artifact durable everywhere, not just here.
- Upload the siblings as assets. Drop the whole directory (or a zip
of it) in, and relative
src,href,srcsetandurl()references are rewritten to the uploaded copies — enough for<script src>, stylesheets, fonts and images, though not for a runtimefetch.
Size: the HTML body is capped at 5 MB; assets are 25 MB per file, 250 MB and 500 files per report.
The part that isn't just hosting
A static host gives you a URL. What it can't give you is the sentence someone was about to type in Slack.
Comma renders the report inside that sandbox with an anchored comment
layer on top. A reader highlights the row, the
paragraph, or the chart caption and pins a thread to it — "this cohort
is double-counted after the July migration" lands on the number it
describes. Threads survive republishing: PATCH the same report id with
a regenerated file and the URL stays, a revision is appended, and open
threads carry across so a reader can see what changed and what was
already argued about.
And it's private by default. A dashboard built from real data belongs behind an access level — private, invite-only, any signed-in user — not on a public static host that happens to be convenient. See the sharing model.
For agents and pipelines
The same endpoint is what an agent or a CI job uses. A Claude Code or
Cursor agent attached through Comma's MCP server calls
create_report with the HTML it just generated and returns the link;
publishing from CI is one curl in the job. Interactive or
static, it's the same write path and the same scoped
token.
Try it
Comma is free — unlimited reports, unlimited commenters, unlimited revision history. Take the dashboard you were about to screenshot and publish the file instead.
Related
- How to share an HTML report — the six properties a shared report needs
- Comment on an HTML report — how anchored threads work
- Publish from CI — the one-curl pipeline pattern
- Share a Jupyter notebook — nbconvert, Plotly, and widget state