This guide walks through everything required to add a new report to the COLD reports system. It covers both simple (non-parameterized) reports and parameterized reports that require user input, using the collaborator report as the worked example for the latter case.
For background on the design intent and security model see report_design_choices.md. For a deep dive on the configuration files see cold_api_deep_dive.md and cold_reports_deep_dive.md.
A report in COLD is the combination of:
- A shell script or program that writes its output to stdout.
- An entry in
cold_reports.yamlthat names the script, defines the output file naming, and (for parameterized reports) declares what inputs are expected. - An option in
views/report_list.hbsso users can select and request the report in the browser. - For parameterized reports: browser-side validation via a TypeScript module or inline JavaScript in the template.
You may also need a change to cold_api.yaml if your report relies on a new SQL query that does not yet exist.
Most reports call dsquery or dataset directly from their shell script, using SQL that is either embedded in the script or a companion .sql file. In that case cold_api.yaml does not need to change.
You do need to add to cold_api.yaml if either of the following is true:
- Your report script calls the
datasetdHTTP API (e.g.,curl http://localhost:8112/api/...) and it needs a named query that does not yet exist. - Your report needs to read from a dataset collection that is not already declared in
cold_api.yaml.
If you do need a new named query, add it under the relevant collection in cold_api.yaml:
- dataset: people.ds
query:
# existing queries ...
my_new_query: |
SELECT json_object('field', src->'field') AS src
FROM people
WHERE src->>'some_field' = ?
ORDER BY src->>'field'Restart datasetd after any change to cold_api.yaml:
# In development
deno task cold_api
# In production — reload via systemd
sudo systemctl restart cold_apiTest the new query directly before proceeding:
curl "http://localhost:8112/api/people.ds/_query/my_new_query" \
-H "Content-Type: application/json" \
-d '{"param": "value"}'Create a shell script in the COLD working directory. The convention is run_<report_name>.bash.
- Write output to stdout. The runner reads stdout and writes it to
htdocs/rpt/<filename>. - Write errors to stderr or prefix the error with
error://on stdout. The runner checks for theerror://prefix and sets the report status to"error"if found. - Exit 0 on success, non-zero on failure. A non-zero exit causes the runner to mark the report as failed.
- Make the script executable:
chmod +x run_my_report.bash
#!/bin/bash
dsquery \
-csv "col1,col2,col3" \
-sql my_report.sql \
people.dsFor SQL embedded in the script:
#!/bin/bash
dsquery \
-csv "family_name,given_name,orcid" \
people.ds <<'SQL'
SELECT json_object(
'family_name', src->'family_name',
'given_name', src->'given_name',
'orcid', src->'orcid'
) AS src
FROM people
ORDER BY src->>'family_name', src->>'given_name'
SQLFor parameterized reports, inputs are passed as positional command-line arguments ($1, $2, ...). The script must validate each argument before using it.
#!/bin/bash
# Validate $1 is a non-empty clpid that exists in people.ds
if [ "$1" = "" ]; then
echo "error://Missing clpid, aborting"
exit 1
fi
CLPID="$1"
# Confirm the clpid exists before doing any work
if ! dataset read people.ds "${CLPID}" > /dev/null 2>&1; then
echo "error://Failed to find '${CLPID}' in people.ds"
exit 1
fi
# Run the report, writing output to stdout
./bin/my_report_program "${CLPID}"See run_collaborator_report.bash for the actual collaborator report implementation. Notice it validates the clpid with dataset read before calling bin/generate_collaborator_rpt. The script is the last line of defence — do not skip the check.
Open cold_reports.yaml and add your report under the reports: key. The key name is the report_name that users will submit in the request form. It must match exactly what the browser POSTs.
run_my_report:
cmd: ./run_my_report.bash
basename: my_report
append_datestamp: false
content_type: text/csv| Field | Notes |
|---|---|
cmd |
Path to the script, relative to the COLD working directory. |
basename |
Base filename without extension. Output goes to htdocs/rpt/<basename><ext>. |
append_datestamp |
Set to true to produce dated snapshots like my_report_2026-04-13.csv instead of overwriting the same file each time. |
content_type |
Determines the file extension: text/csv → .csv, application/yaml → .yaml, application/vnd.ms-excel → .xlsx, text/plain → .txt. |
run_my_parameterized_report:
cmd: ./run_my_parameterized_report.bash
inputs:
- id: clpid
type: clpid
required: true
basename: "{{clpid}}_my_report"
append_datestamp: false
content_type: text/csvThe inputs list must be declared in the order they will be passed as command-line arguments to cmd. Each input has:
| Field | Notes |
|---|---|
id |
The form field name and the {{id}} placeholder in basename. |
type |
HTML5 input type (text, email, etc.) or an identifier type from metadatatools (clpid, orcid). Used for validation in the middleware and runner. |
required |
If true, the runner rejects the request if this value is absent or empty. |
The basename may use {{id}} placeholders that are substituted with the validated input values at run time. In the example above, a request with clpid = Doiel-R-S produces output at htdocs/rpt/Doiel-R-S_my_report.csv.
Important: Any input used in basename must be required: true, otherwise the filename template cannot resolve and the output path will contain _id_ as a placeholder.
Add an <option> element inside the appropriate <optgroup> in the <select name="report_name"> block. Choose the group that best describes your report, or add a new <optgroup> if needed.
<optgroup label="CSV reports">
<!-- existing options ... -->
<option value="run_my_report" title="Description of what this report contains">Generate My Report CSV</option>
</optgroup>The value attribute must exactly match the key you added in cold_reports.yaml.
That is all for a simple report. No other template changes are needed — the form POSTs the selected report_name and an optional emails field, and the middleware handles the rest.
Because the main report_list.hbs form is a single <select> with a submit button, you have two options for introducing report-specific input fields:
Option A — Inline parameter fields in report_list.hbs (recommended for simple inputs)
Show extra input fields when the parameterized report is selected, and hide them otherwise. This keeps everything on the existing reports page.
Option B — Separate standalone page (current collaborator report approach)
Create a dedicated HTML page and TypeScript module for the report. Link to it from report_list.hbs. This is what collaborator_report.html and collaborator_report.ts do today. It gives more room for a richer UI (autocomplete, validation feedback) but requires maintaining an additional file.
The collaborator report is currently implemented as Option B and has a FIXME to eventually move to Option A. Choose Option A for new reports unless the browser-side interaction is significantly more complex than a single validated text input.
The technique is to add hidden <div> elements for each parameterized report's inputs and show/hide them using a change event listener on the <select>.
1. Add the option to the select (uncomment the collaborator example or add your own):
<optgroup label="Mediated reports">
<option value="run_my_parameterized_report"
title="Run a parameterized report"
data-inputs="my-report-inputs">My Parameterized Report</option>
</optgroup>The data-inputs attribute names the id of the <div> holding this report's parameter fields.
2. Add a hidden <div> for the report's parameters, inside the <form> block, below the <select>:
<div id="my-report-inputs" class="report-inputs" style="display:none">
<label for="my_param">My Parameter</label>
<input type="text"
id="my_param"
name="my_param"
placeholder="Enter value"
autocomplete="off"
disabled>
<small>Description of what this parameter means.</small>
</div>Set the inputs disabled by default. The JavaScript below will enable them when the report is selected. A disabled input is not included in the form POST, which prevents unintended parameters from reaching the middleware.
3. Add a <script> block at the bottom of the <body> (before {{>footer}}) to wire the show/hide logic:
<script>
(function () {
const select = document.getElementById('report_name');
const allInputDivs = document.querySelectorAll('.report-inputs');
function updateInputVisibility() {
const chosen = select.options[select.selectedIndex];
const targetId = chosen.dataset.inputs || '';
allInputDivs.forEach(function (div) {
if (div.id === targetId) {
div.style.display = '';
div.querySelectorAll('input, select, textarea').forEach(function (el) {
el.disabled = false;
});
} else {
div.style.display = 'none';
div.querySelectorAll('input, select, textarea').forEach(function (el) {
el.disabled = true;
});
}
});
}
select.addEventListener('change', updateInputVisibility);
// Run once on page load in case the browser restores a selection.
updateInputVisibility();
}());
</script>This is vanilla JavaScript with no dependencies, consistent with COLD's development philosophy.
4. Add browser-side validation before the form submits (optional but recommended):
<script>
document.getElementById('report-request-form').addEventListener('submit', function (e) {
const reportName = document.getElementById('report_name').value;
if (reportName === 'run_my_parameterized_report') {
const param = document.getElementById('my_param').value.trim();
if (param === '') {
e.preventDefault();
alert('My Parameter is required for this report.');
return;
}
// Add any identifier-format validation here (e.g. /^[A-Za-z]+-[A-Za-z]-[A-Za-z]$/.test(param))
}
});
</script>This approach uses a dedicated HTML page and a TypeScript module. It is the current state of the collaborator report.
Files involved:
htdocs/collaborator_report.md— Source Markdown page (rendered to HTML by Pandoc as part of the build)htdocs/collaborator_report.html— Rendered HTML page served directlycollaborator_report.ts— TypeScript module for the browser-side UIhtdocs/modules/collaborator_report.js— Transpiled JavaScript (generated bydeno task htdocs)
How the collaborator report browser UI works:
collaborator_report.ts exports a CollaboratorReportUI class. When instantiated on the page it:
- Renders a form with a
clpidtext input backed by a<datalist>for autocomplete. - Calls
GET /api/people.ds/get_all_clpid(viaClientAPI) to populate the autocomplete list. - On form submit, validates the entered
clpidagainstGET /api/people.ds/validate_clpid?clpid=<value>before sending anything. - If valid, POSTs to
../reportswithreport_name=run_collaborator_reportandclpid=<value>. This is the same endpoint thereport_list.hbsform uses.
The page collaborator_report.html loads the module:
<div id="collaborator_report"></div>
<noscript>JavaScript required for the collaborator report</noscript>
<script type="module">
import { ClientAPI } from "./modules/client_api.js";
import { CollaboratorReportUI } from "./modules/collaborator_report.js";
const baseUrl = URL.parse(window.location.href);
baseUrl.pathname = baseUrl.pathname.replace(/collaborator_report.html$/g, '');
baseUrl.search = "";
const reportElement = document.getElementById("collaborator_report");
window.addEventListener('DOMContentLoaded', (event) => {
const ui = new CollaboratorReportUI({
baseUrl: baseUrl,
reportElement: reportElement,
clientAPI: new ClientAPI(baseUrl)
});
});
</script>To add a new report using Option B:
- Create
my_report.tsfollowing the structure ofcollaborator_report.ts. Export a class that renders the form, validates inputs, and POSTs to../reports. - Rebuild the browser modules:
deno task htdocs(this bundlesmy_report.tsintohtdocs/modules/my_report.js). - Create
htdocs/my_report.htmlorhtdocs/my_report.mdembedding a<div id="my_report">and a<script type="module">block that imports and instantiates your class. - Add a link from
views/report_list.hbstomy_report.htmlso users can find it.
When to use Option A vs Option B:
| Choice | Option A (inline) | Option B (separate page) |
|---|---|---|
| Input count | 1–2 simple fields | Many fields or complex UI |
| Validation | Basic HTML5 / regexp | Requires API lookups (e.g., validate clpid exists) |
| Autocomplete | Not needed | Needed (datalist populated from API) |
| JavaScript dependency | None (vanilla JS) | TypeScript module required |
| Maintenance | One file (report_list.hbs) |
Multiple files (*.ts, *.html, *.js) |
The cold_reports service reads cold_reports.yaml once at startup. Any changes to the file require a restart:
# In development
deno task cold_reports
# In production
sudo systemctl restart cold_reports- Open the COLD reports page in the browser.
- Select your new report (and fill in any parameters).
- Submit the request.
- Watch the reports table update — status should move from
requested→processing→completed. - Follow the link to confirm the output file looks correct.
If the report stays in requested status, check that the cold_reports service is running and that the report_name submitted exactly matches the key in cold_reports.yaml.
If the status goes to error, read the link field in the report queue object for the error message:
dataset keys reports.ds | tail -1 # get the most recent UUID
dataset read reports.ds <uuid> # inspect status and linkThen run the script directly to see its stderr:
./run_my_report.bash [args]Every new parameterized report must address these points. See report_design_choices.md for the full rationale.
- Browser: The form validates that required fields are non-empty and that values conform to the expected format (regexp or API lookup) before submitting.
- Middleware (
cold_reports.ts):handleReportRequestreadscold_reports.yamlto get the input definitions, merges form values into theReportobject, and rejects any parameter not declared in theinputslist. - Runner (
cold_reports.ts,resolveCommandInputs): Inputs are matched byidandtypebefore being passed to the command. Any mismatch substitutes an empty value. Only declared inputs are forwarded. - Script (
run_my_report.bash): The script validates each$1,$2, ... argument before using it. A missing or malformed argument printserror://messageand exits non-zero. - Parameters are strings only: Inputs must never be used to construct a shell command in a way that allows injection. Pass values as positional arguments, not interpolated into a command string.
- No free-text inputs: Use identifiers with a defined format (e.g.,
clpid,orcid) or constrained<select>menus. Never accept arbitrary free text as an input to a parameterized report. - Filename safety: If the parameter contributes to the output filename (via
basenametemplate), confirm the value cannot traverse directories. The runner does not currently sanitise paths, so this is the script author's responsibility.
| File | Change |
|---|---|
run_<name>.bash |
Create the script |
cold_reports.yaml |
Add the report entry |
views/report_list.hbs |
Add <option> to the select |
cold_api.yaml |
Only if a new SQL query is needed |
Restart: cold_reports (and datasetd if cold_api.yaml changed).
| File | Change |
|---|---|
run_<name>.bash |
Create the script, validate $1, $2, ... |
cold_reports.yaml |
Add the report entry with inputs: list |
views/report_list.hbs |
Add <option> with data-inputs, hidden <div> with inputs, show/hide script, submit validation script |
cold_api.yaml |
Only if a new SQL query is needed |
Restart: cold_reports (and datasetd if cold_api.yaml changed).
| File | Change |
|---|---|
run_<name>.bash |
Create the script, validate $1, $2, ... |
cold_reports.yaml |
Add the report entry with inputs: list |
<name>.ts |
Create the TypeScript UI class |
htdocs/<name>.html |
Create the standalone HTML page |
views/report_list.hbs |
Add a link to the standalone page |
cold_api.yaml |
Only if a new SQL query is needed |
Build: deno task htdocs (to transpile the TypeScript module).
Restart: cold_reports (and datasetd if cold_api.yaml changed).