Templates
A template turns what a pipeline reads from Health into the text it delivers. A template is one text file: a small header, then a body in a subset of Liquid. This document is the contract: what’s here keeps working across versions.
---name: Daily stepsformat: csvdata: [metrics]partition: dayfilename: "steps-{{ partition.key }}.csv"---date,steps,source{% for p in metrics.step_count.data %}{{ p.date | date: '%Y-%m-%d' }},{{ p.qty }},{{ p.source }}{% endfor %}Header
Section titled “Header”| Key | Meaning |
|---|---|
name |
Required. What the app shows. |
description |
One line about it. |
format |
json, csv, markdown, html, xml, gpx or text. Defaults to text. |
data |
The sections it needs: metrics, workouts, symptoms, ecg, heart_rate_notifications, state_of_mind, cycle_tracking, medications. |
partition |
Runs the template once per none, day, week, month, year, metric, section or workout, each producing one part. Defaults to none. |
filename |
A template for each part’s name. / becomes -, so a name can’t leave its folder. |
content_type |
Overrides the format’s media type. |
skip_empty |
true leaves out a part that read no rows, like a workout with no route. |
An unknown key is an error, so a typo doesn’t pass silently.
Safe by format
Section titled “Safe by format”{{ value }} always produces something valid where it’s written:
| Format | What {{ value }} does |
|---|---|
json |
Writes a JSON value: text gets its own quotes, a list or object is written whole, a missing value is null. |
csv |
Quotes a field that needs it. Text starting with =, +, - or @ also gets a leading ', so a spreadsheet doesn’t run it as a formula. Numbers are left alone. |
html, xml, gpx |
Escapes &, <, >, " and '. |
markdown |
Escapes \ and ` |
text |
Writes it as it is. |
{{ value | raw }} opts out, for example inside quotes you wrote yourself in JSON:
"id-{{ id | raw }}".
json output is checked as it’s written, and xml and gpx have their tags
checked. Output that doesn’t parse fails the run rather than sending bad data.
| Variable | What it is |
|---|---|
export |
start, end, generated_at, format, time_zone, grouping, device.name, device.model, device.os, device.app |
partition |
kind, key, start, end, section, metric |
metrics |
By name, metrics.step_count. In a loop, the metrics that have data. |
workouts |
Each workout, oldest first. |
workout |
The current one, in a workout partition. |
symptoms |
name, severity, source, start, end, userEntered, cycleStart |
cycle_tracking, heart_rate_notifications |
name, value when the type has values, source, start, end, userEntered, cycleStart |
ecg |
start, end, classification, averageHeartRate, samplingFrequency, numberOfVoltageMeasurements, source, voltageMeasurements (date, voltage, units) |
state_of_mind |
kind, valence, valenceClassification, labels, associations, source, start, end |
medications |
name, nickname, status, quantity, scheduledQuantity, scheduledDate, unit, source, start, end |
A metric has name, title, group, units and data, its points. Writing {{ m }} in
JSON leaves start and end off activity totals such as steps and energy, whose samples are many
and short, though p.start and p.end still work. What’s on
a point depends on the metric and whether it’s summarized:
| Point | Fields |
|---|---|
| Most metrics, summarized | date, qty, source |
| Heart rate, summarized | date, Min, Avg, Max (also min, avg, max), and no source |
| Blood pressure | date, systolic, diastolic, source |
| Sleep, summarized | date, totalSleep, core, deep, rem, awake, asleep, inBed, inBedStart, inBedEnd, sleepStart, sleepEnd, source |
| Unsummarized | date, start, end, qty, source; heart rate adds Min, Avg, Max, context; heart rate variability can add heartbeatSeries; sleep has value, startDate, endDate |
source lists several sources separated by |. Every point also has units.
A workout has id, name, activity_type, start, end, duration,
isIndoor, location, source.name, source.identifier, metadata and
has_route. These are {qty, units} when recorded: activeEnergyBurned,
totalEnergy, distance, speed, avgSpeed, maxSpeed, avgHeartRate,
maxHeartRate, flightsClimbed, stepCadence, elevationUp, temperature,
humidity, intensity and estimatedEffortScore. heartRate has avg, max and min.
Lists read from Health only if you use them: route, heartRateData, heartRateRecovery,
activeEnergy, basalEnergy, stepCount, walkingAndRunningDistance, cyclingDistance,
segments and splits. activities and events come with the workout.
statistics holds every statistic HealthKit recorded, each with sum, avg, min, max and units.
A route point has timestamp, latitude,
longitude, altitude, course, courseAccuracy, speed, speedAccuracy,
horizontalAccuracy and verticalAccuracy.
Dates write as 2026-10-24 17:00:00 -0700 unless you use date or iso8601.
Writing {{ point }} or {{ metric }} in JSON writes the whole thing.
Language
Section titled “Language”Tags: if/elsif/else, unless, case/when, for with limit,
offset, reversed and else, break, continue, assign, capture, raw,
comment, and whitespace control with {%- and -%}.
There’s no include or render. A template can’t reach the network, other files
or anything but the data it’s given.
{% capture %} holds plain text, escaped only when you write it, so it’s safe to build a string
from pieces in any format.
forloop: index, index0, first and last always work. length, rindex
and rindex0 need the whole list in hand, so they’re empty when walking Health data
and work on lists you’ve made with a filter.
Filters: abs, append, at_least, at_most, capitalize, ceil, compact,
concat, date, default, divided_by, downcase, escape, escape_once, first,
floor, group_by, iso8601, join, json, last, lstrip, map, minus,
markdown_tree, modulo, newline_to_br, plus, prepend, raw, remove, remove_first, replace,
replace_first, reverse, round, rstrip, size, slice, sort, sort_natural,
split, strip, strip_html, strip_newlines, sum, times, truncate,
truncatewords, uniq, upcase, url_decode, url_encode, where.
markdown_tree writes an object as nested Markdown: its plain fields as a Summary
table, each nested object under its own heading, rows of plain fields as fenced CSV. Give it
the level to start at: {{ workout | markdown_tree: 3 }}.
date takes strftime conversions: %Y %y %m %d %e %j %H %I %M %S %L %p %P %z %:z %Z %A %a %B %b %h %u %w %s %F %T %D %%.
Numbers write the way JavaScript writes them, so {{ 3 | divided_by: 2 }} is 1.5.
Only nil and false are false; 0 and "" are true, as in all Liquid.
The native format’s data
Section titled “The native format’s data”push holds the native format’s body for the run’s days: schema, device, exportedAt,
tz, days, stats, samples, workouts, summaries and ecg, as in
schema.md. The built-in Health Data Relay template writes it out field by field,
so copying it is the way to customize that format.
Calendar events
Section titled “Calendar events”A template for a calendar destination writes JSON: {"events": [...]}, each with a key that stays the same for the
same event, a title and a start, and optionally end, allDay, notes and location.
Large data
Section titled “Large data”A loop reads Health a page at a time and output goes straight to disk, so a template
can write far more than fits in memory. A week of one reading a second is about 600,000
rows. Anything that needs every row at once loads them: sort, sort_natural, map,
where, group_by, join, size, last, uniq, reverse and looping with reversed.
Those are capped at 500,000 rows, and a list past that fails its run with a message.
A run has limits on output size, loop rows and time. A template that hits one fails its own run, with a line number, and nothing else is affected.
Checking the language
Section titled “Checking the language”fixtures/templates/conformance.json holds cases run through both this engine and
liquidjs, npm test in tools/conformance. A case passing
in both means the subset agrees with a real Liquid implementation.