A minipage is a folder of static files (HTML, CSS, JS) that people or agents publish
for colleagues, without git or claude.ai. The dashboard shows it to signed-in people
at https://dashboard.fra.virdx.dev/minipages/<slug>. Use it instead of claude.ai
artifacts or zipped HTML reports: patient data stays on our infrastructure.
- A page is a
Minipageresource in vxData; its files sit in vxData storage. Republishing keeps old versions in the resource history. - Pages hold no patient data. They load it at view time through
/api/platform/..., as the person viewing the page. - People comment on a page, anchored to its sections, charts and tables. Comments are
ResourceComments with theVIRDXlicense, so agent tokens cannot read them.
Publish
Tell your agent: “Build a minipage in a scratch folder. Follow the instructions for agents at https://docs.fra.virdx.dev/dashboard/minipages/index.md”. Then publish the folder with the vx CLI:
export VXDATA_TOKEN=... # your vxData token
vx minipage deploy my-report ./my-report \
--title "Synthetic grading ladder" \
--description "What each change buys on real scans." \
--author "Jane Doe (with Claude)"Open https://dashboard.fra.virdx.dev/minipages/my-report. Run the same command to
republish. The dashboard shows title as the heading, description under it and
author in the list. Slugs starting with _ are reserved (_kit).
Limitations
These are deliberate, for now:
- Static files only: no server code and no build step on our side.
- Page JS runs on the dashboard origin with the viewer’s session. Only publish code you trust; there is no sandbox or CSP on outgoing requests yet.
- Every signed-in person sees every page; there is no per-page access control.
- Any vxData token can read the published files, so files must never contain patient data.
- Agents cannot see rendered pages (they lack patient data access), so they cannot debug them visually.
authoris set by the publisher or the browser and is not verified.- Comments anchor to element ids; renaming an id detaches its comments. Deleting a comment deletes its replies.
- No delete command and no rollback UI: older versions exist only in the vxData history.
Instructions for agents
A minipage is a folder of static files. It is published to the minipages
bucket, registered as a Minipage resource in vxData, and shown at
/minipages/<slug> in the dashboard, inside an iframe on the dashboard
origin. Only signed-in people can open it. You (the agent) can publish but
never read it back.
Rules
- No patient data in the files. Load it at view time with
query()from the kit; the request runs as the person viewing the page. - No external URLs: no CDNs, fonts, analytics or APIs outside
/api/.... Bundle npm packages withbun buildinstead. - Use the kit below for look and charts. Add page-specific CSS in a
<style>block only when no kit class fits. - Write plain HTML + ES modules. TypeScript is fine if you bundle it.
- Keep the minipage out of git. Build it in a scratch folder.
- Give every section, figure, chart and table a stable
id. Comments anchor to ids; an id that disappears on republish detaches its threads.
Page skeleton
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My minipage</title>
<link rel="stylesheet" href="../_kit/vx.css" />
</head>
<body>
<main class="vx-page">
<div class="vx-eyebrow">Project · topic</div>
<h1 class="vx-title">Title</h1>
<p class="vx-byline">2026-10-12 · Author</p>
<p class="vx-lead">One-sentence answer first.</p>
<section class="vx-section" id="results"><h2>Results</h2> ...</section>
</main>
<script type="module">
import { syncTheme, barChart } from '../_kit/vx.js'
syncTheme()
</script>
</body>
</html>CSS classes (vx.css)
| Class | Use |
|---|---|
vx-page (.wide) |
page container, 1040 px (1400 px) |
vx-eyebrow, vx-title, vx-byline, vx-lead |
page header (serif title) |
vx-col |
limit prose to a 72ch reading column |
vx-section + h2/h3 |
sections; give each an id for vx-toc links |
vx-toc |
<ul> of in-page links |
vx-grid-2, vx-grid-3, vx-stack |
layout |
vx-card |
bordered surface |
details.vx-fold > summary (+ span.vx-muted hint) |
collapsible block: methods, configs, long tables |
vx-kpis > vx-kpi (.label .value .note) |
headline numbers |
vx-callout (.warn, .danger) |
takeaways and warnings |
vx-issue > .label + h3 |
open problem or failure mode |
vx-badge (.primary .success .warning .danger) |
inline status |
vx-delta (.up, .down) |
signed change, e.g. in a ladder table |
vx-tag |
small mono label, e.g. a run id |
vx-table-wrap > table.vx-table (.compact) |
tables; td.num right-aligns, .best highlights, th[data-sort] sorts |
figure.vx-figure > div + figcaption |
chart with caption |
div.vx-plate > img |
white plate for a PNG figure (readable in dark mode) |
ol.vx-steps > li > b + span |
pipeline / step diagram |
vx-muted, vx-small, vx-status (.error) |
secondary text, live-data status |
vx-viewer |
box for the volume viewer |
JS helpers (vx.js)
| Function | Does |
|---|---|
syncTheme() |
follow the dashboard’s dark mode; call once |
query(payloadType, body?) |
vxData rows as the viewer; see Loading data |
objectUrl(s3Url) |
browser-readable URL for an s3:// object |
barChart(div, { categories, series: [{ name, values }], horizontal?, min?, max?, format? }) |
grouped bar chart with legend + hover |
lineChart(div, { series: [{ name, points: [[x, y]], dashed?, line? }], x?: { label, min, max, log }, y?: { label, min, max }, diagonal?, height?, format? }) |
curves (ROC, scaling, training) with hover readout; legend click hides a series; line: false draws points |
heatTable(table, { min?, max? }) |
colour td.num cells low (red) to high (green) |
sortableTable(table) |
enable th[data-sort] sorting |
volumeViewer(div) → { show(s3Url) } |
niivue viewer for NIfTI volumes |
Chart choice: one measure across items → bars (horizontal when labels are
long); several series per item → grouped bars; signed values → bars about
zero; a measure over a continuous x (ROC, data size, epochs) → lineChart
(diagonal for ROC chance, x.log for sizes). Put methods, configs and long
tables in vx-fold blocks so the page reads top-down. Put the numbers in a table only when readers look them up.
Loading data with query()
query(payloadType, body?) posts to vxData’s /query as the viewer and returns
the results rows. Each row has the payload fields plus identifier,
parent_identifier, datasource_id, license and created_at. body is
merged into the request; the default limit is 1000 and there is no paging,
so narrow with filters or raise limit.
// Every patient (up to 1000), newest first
const patients = await query('Patient')
// One filter: { field, operator, value }. Values are case-sensitive.
const t2w = await query('Volume', {
filters: [{ field: 'volume_type', operator: 'eq', value: 't2w' }],
})
// Several filters are AND-ed
const basel = await query('Volume', {
filters: [
{ field: 'volume_type', operator: 'in', value: ['t2w', 'adc'] },
{ field: 'datasource_id', operator: 'eq', value: 'datasource/basel' },
],
})
// OR (and nesting) with a group: { operator: 'and' | 'or', conditions }
const either = await query('Patient', {
filters: [{
operator: 'or',
conditions: [
{ field: 'default_split', operator: 'eq', value: 'test' },
{ field: 'default_split', operator: 'eq', value: 'benchmark' },
],
}],
})
// Everything under one resource: direct children, or all descendants
const volumes = await query('Volume', {
parent_identifier: 'patient/basel/wM42jf3',
include_indirect_parents: true,
})
// Sort and cap
const latest = await query('BenchmarkingResult', {
filters: [{ field: 'eval_task', operator: 'starts_with', value: 'nc:' }],
sort_by: 'created_at',
sort_order: 'desc',
limit: 20,
})fieldis a payload field, or one ofidentifier,parent_identifier,license,created_at,valid_from,source_ref,patient_id,study_id,datasource_id. An unknown field fails the request.- Operators:
eqnegtgteltlteinbetweencontainsstarts_withends_withbeforeafteris_nullis_not_null(value: nullfor the last two) andoverlaps. - Rows are filtered to what the viewer may read; the same page can show different rows to different people. Show the row count.
- Payload types and their fields:
GET /api/platform/schemas, or the vxData docs.
Files, charts and viewers
// An s3:// path from a row (e.g. Volume.path_nii, HistoScan.url) -> fetchable URL
const url = await objectUrl('s3://vxdata/giga_3d/.../scene.json')
const scene = await (await fetch(url)).json()
img.src = await objectUrl(row.url) // images work the same way
// Bars: categories x series
barChart(document.getElementById('auc'), {
categories: ['Site A', 'Site B'],
series: [{ name: 'S0', values: [0.61, 0.55] }, { name: 'S5', values: [0.76, 0.68] }],
min: 0.5,
})
// Curves: points are [x, y]; ROC with a chance line, sizes on a log axis
lineChart(document.getElementById('roc'), {
series: [{ name: 'Model', points: [[0, 0], [0.2, 0.6], [1, 1]] }],
x: { label: 'False positive rate', min: 0, max: 1 },
y: { label: 'True positive rate', min: 0, max: 1 },
diagonal: true,
})
// Tables: sort on th[data-sort], colour td.num from low to high
sortableTable(document.getElementById('runs'))
heatTable(document.getElementById('runs'), { min: 0.5, max: 0.9 })
// NIfTI viewer in a .vx-viewer box
const viewer = await volumeViewer(document.getElementById('viewer'))
await viewer.show(t2w[0].path_nii)