oura-cli
oura-cli downloads the user's Oura Ring data into ~/.oura-cli/oura.db and prints it. After one oura-cli sync, every report and summary is answered from that file without a network call.
Rules
- Always pass
--format json. Output format is otherwise guessed from whether stdout is a terminal, and an agent's shell is often neither reliably one nor the other.
- Cache first. Answer from
db today, db date, db week, db trends, db rows or report. Run sync when doctor says the data is stale or the user asks for the latest. Use fetch only when the cache cannot answer (the user wants the exact API record, or a range older than the cache).
- Never ask for or print the token.
oura-cli login is interactive and hides the input. If there is no token, tell the user to run oura-cli login themselves, or to export OURA_TOKEN in the environment the agent runs in. Do not echo ~/.oura-token, OURA_TOKEN, or the --token flag into a transcript.
- This is personal health data. Keep it on the machine. Do not paste rows into a third-party service or file outside the user's request without saying so first.
- Gate on
.ok, not the exit code. healthcheck always exits 0 once the probe ran, and doctor exits 0 for a warn (stale or empty cache) even though ok is false. doctor exits 2 when a token check fails and 4 when the database check fails, so a non-zero exit is a finding, not a crash.
Preflight
oura-cli doctor --offline --format json
Returns {ok, checks[], nextStep}. Each check is {id, status: "ok"|"warn"|"fail"|"skip", detail}; nextStep is the first fix to apply, or null. What each id means when it is not ok:
id |
Meaning |
What to do |
token |
no token found |
user runs oura-cli login, or sets OURA_TOKEN |
token-valid |
Oura rejected the token (only without --offline) |
user needs a fresh PAT from cloud.ouraring.com |
database |
cache missing or cannot open |
oura-cli sync creates it; a corrupt file must be deleted first |
integrity |
SQLite quick_check failed |
delete the file, then oura-cli sync |
data |
newest cached day ended more than 36 h ago |
oura-cli sync; if still stale, the ring has not uploaded (user opens the Oura app near the ring) |
Drop --offline when you also want to know whether Oura has newer data than the cache: one API call to validate the token, and up to one per collection to look for newer data when the cache is stale.
Which command answers which question
| The user asks about |
Run |
Shape |
| today / how did I sleep last night |
oura-cli db today --format json |
one object, see below |
| a specific day |
oura-cli db date 2026-09-01 --format json |
same object; every field null when the day is not cached |
| the last 7 days, or yesterday |
oura-cli db week --format json |
array of 7 day objects ending today, so yesterday is the second-to-last; prefer this to computing dates yourself |
| a weekly or monthly digest, deltas, recommendations |
oura-cli report --format json or oura-cli report --period month --format json |
{period, weekStart, weekEnd, days[], completeThrough, lastUpload, averages[], spo2, patterns, sleepDetails, recommendations[]}; lastUpload is the newest heart-rate sample in the cache, ISO 8601 UTC, and lags the daily summaries by days; see below for recommendations |
| a trend over N days |
oura-cli db trends 30 --format json |
array of {label, avg, min, max, count} |
| how much data there is, personal bests |
oura-cli db stats --format json |
{tables[], dateRange, trends[], records} |
| detail no summary shows (workouts, tags, sessions, HRV samples, battery, ring hardware) |
oura-cli db rows <collection> [range] --format json |
array of Oura records as cached |
| the exact record straight from Oura |
oura-cli fetch <collection> [range] |
array of Oura records; always JSON |
| refresh the cache |
oura-cli sync --format json |
{import: {startDate (the oldest resume day across collections), endDate, fetched, added, removed, dropped, refused, pruned, isFirstSync}, today}; a non-empty refused names rows sync kept because the API stopped returning them, which oura-cli sync --prune=<collection> applies once the user agrees, and that run adds import.pruneScope ("all" or the collections named) |
A day object from db today, db date and db week:
{
"day": "2026-09-01", "partial": false,
"sleep_score": 84, "readiness_score": 79, "activity_score": 71, "steps": 9412,
"stress": "normal", "spo2": 97.4, "temp_deviation": -0.1,
"sleep_hours": 7.3, "deep_hours": 1.6, "rem_hours": 1.5,
"avg_hrv": 52, "lowest_hr": 48, "efficiency": 91
}
Every field except day and partial can be null. Today is often all null until the morning, because Oura publishes a day's summary only after that night's sleep syncs from the ring. A past day that is all null is not cached: run oura-cli db date <day> --format table once, the one deliberate exception to rule 1: the table output says whether the day is before the cache begins, after it ends, or in a gap, and which sync call would fill it, and the JSON does not. Pass that hint on rather than guessing. When every day is null in db week and report as well, the cache is empty: doctor says so (data: warn, nextStep: oura-cli sync). A --db path that does not exist is created as a new empty cache rather than rejected, so check the path before concluding there is no data.
report.recommendations holds codes, not sentences: sleep_low, sleep_great, readiness_low, readiness_great, steps_low, steps_great. Phrase them yourself; the table output has one line per code.
Collections
sleep readiness activity hr spo2 stress workout sleep-periods cv-age resilience vo2max sleep-time session rest-mode tags ring battery
Range flags for db rows and fetch: --day YYYY-MM-DD (default: today), --from YYYY-MM-DD --to YYYY-MM-DD (both required), or --days N. ring is a hardware snapshot and takes no range. hr and battery are timeseries keyed by timestamp: a single day of hr is hundreds of rows, so add --limit N on db rows or narrow the day before reading it into context.
oura-cli db rows workout --days 7 --format json # last week's workouts
oura-cli db rows tags --from 2026-08-01 --to 2026-08-31 --format json
oura-cli db rows hr --day 2026-09-01 --limit 50 --format json
oura-cli db rows ring --format json # ring model, size, firmware
oura-cli fetch sleep-periods --day 2026-09-01 # bedtime, HRV, per-period detail
Reading the numbers
- Scores are 0–100. Oura's own bands: 85+ optimal, 70–84 good, under 70 pay attention.
report flags days with sleep or readiness under 70 and activity at 90 or above.
partial: true marks a day whose activity is still accumulating (normally today). Do not compare its activity score or steps with closed days; report already leaves such days out of the activity averages and out of the recommendations, and says so in completeThrough.
temp_deviation is degrees Celsius against the user's baseline; a sustained rise with a readiness drop is what Oura itself reads as a possible illness signal. Report it, do not diagnose.
stress is Oura's day summary (restored, normal, stressful) or null.
--tz Europe/Helsinki (or OURA_TZ) sets the timezone --day and today are interpreted in; default is the system timezone. One quirk: day on cached hr and battery rows is the date in Oura's UTC timestamp, not the local day.
Errors and exit codes
Errors are a single JSON object on stderr:
{"error":{"code":"BAD_ARGS","message":"<day> must be a real YYYY-MM-DD date, got \"tomorrow\".","hint":"Write <day> as a calendar day, e.g. 2026-09-01."}}
code and message are always present. hint is optional: it is there when the CLI has a fix to suggest, and absent otherwise (an unexpected internal error, some argument conflicts), so treat a missing hint as no suggestion, not as a malformed payload.
| Exit |
Meaning |
Typical code |
Usually means |
| 0 |
success (check .ok on doctor / healthcheck) |
|
|
| 1 |
user error |
BAD_ARGS |
fix the arguments |
| 2 |
auth |
TOKEN_MISSING, TOKEN_INVALID |
user runs oura-cli login |
| 3 |
API or network |
API_ERROR |
a 429 was already retried for up to three minutes; wait before retrying a long fetch hr |
| 4 |
local storage |
DB_ERROR |
see doctor |
Discovering the rest
oura-cli describe prints a JSON manifest of every command, argument, enum value, exit code and the JSON Schema file for each output. Read it instead of scraping --help.
oura-cli healthcheck is a liveness probe: {ok, version, latencyMs}.
- Global flags
--format, --db, --tz, --token, --no-color may appear anywhere on the line.
- JSON Schemas for
fetch <collection>, doctor and describe ship in the package under docs/schemas/.
1---2name: oura-cli3description: Read the user's Oura Ring sleep, readiness and activity. Also steps, HRV, resting heart rate, SpO2, stress, workouts, temperature, VO2 max and ring battery, through the oura-cli command. Use when the user asks how they slept, how ready or recovered they are, what their scores, trends or weekly report look like, or wants raw Oura records for a day or range. Data is cached in a local SQLite file; nothing leaves the machine except calls to the Oura API.4license: MIT5compatibility: Requires Bun and the oura-cli binary on PATH (bun add -g @drakulavich/oura-cli). Network access only for `sync` and `fetch`; every other command reads the local cache.6# The Agent Skills spec types `metadata` as string → string. The two harnesses this file targets7# read nested objects under their own key instead (OpenClaw JSON5-parses the whole block and8# ignores `openclaw` unless it is an object; Hermes documents `hermes.tags` as a list), so the9# vendor keys below are objects by design. Every other key stays a string; skills/oura-cli.test.ts10# holds the contract.11metadata:12 source: https://skillmd.com/skills/drakulavich/oura-cli13 openclaw:14 emoji: "💍"15 homepage: https://github.com/drakulavich/oura-cli16 requires:17 bins:18 - oura-cli19 install:20 - id: bun21 kind: node22 package: "@drakulavich/oura-cli"23 bins:24 - oura-cli25 label: Install oura-cli (bun add -g)26 hermes:27 tags:28 - oura29 - health30 - sleep31 - readiness32 - wearable33 category: health34---3536# oura-cli3738`oura-cli` downloads the user's Oura Ring data into `~/.oura-cli/oura.db` and prints it. After one `oura-cli sync`, every report and summary is answered from that file without a network call.3940## Rules41421. **Always pass `--format json`.** Output format is otherwise guessed from whether stdout is a terminal, and an agent's shell is often neither reliably one nor the other.432. **Cache first.** Answer from `db today`, `db date`, `db week`, `db trends`, `db rows` or `report`. Run `sync` when `doctor` says the data is stale or the user asks for the latest. Use `fetch` only when the cache cannot answer (the user wants the exact API record, or a range older than the cache).443. **Never ask for or print the token.** `oura-cli login` is interactive and hides the input. If there is no token, tell the user to run `oura-cli login` themselves, or to export `OURA_TOKEN` in the environment the agent runs in. Do not echo `~/.oura-token`, `OURA_TOKEN`, or the `--token` flag into a transcript.454. **This is personal health data.** Keep it on the machine. Do not paste rows into a third-party service or file outside the user's request without saying so first.465. **Gate on `.ok`, not the exit code.** `healthcheck` always exits 0 once the probe ran, and `doctor` exits 0 for a `warn` (stale or empty cache) even though `ok` is `false`. `doctor` exits 2 when a token check fails and 4 when the database check fails, so a non-zero exit is a finding, not a crash.4748## Preflight4950```bash51oura-cli doctor --offline --format json52```5354Returns `{ok, checks[], nextStep}`. Each check is `{id, status: "ok"|"warn"|"fail"|"skip", detail}`; `nextStep` is the first fix to apply, or `null`. What each `id` means when it is not `ok`:5556| `id` | Meaning | What to do |57|---|---|---|58| `token` | no token found | user runs `oura-cli login`, or sets `OURA_TOKEN` |59| `token-valid` | Oura rejected the token (only without `--offline`) | user needs a fresh PAT from cloud.ouraring.com |60| `database` | cache missing or cannot open | `oura-cli sync` creates it; a corrupt file must be deleted first |61| `integrity` | SQLite `quick_check` failed | delete the file, then `oura-cli sync` |62| `data` | newest cached day ended more than 36 h ago | `oura-cli sync`; if still stale, the ring has not uploaded (user opens the Oura app near the ring) |6364Drop `--offline` when you also want to know whether Oura has newer data than the cache: one API call to validate the token, and up to one per collection to look for newer data when the cache is stale.6566## Which command answers which question6768| The user asks about | Run | Shape |69|---|---|---|70| today / how did I sleep last night | `oura-cli db today --format json` | one object, see below |71| a specific day | `oura-cli db date 2026-09-01 --format json` | same object; every field `null` when the day is not cached |72| the last 7 days, or yesterday | `oura-cli db week --format json` | array of 7 day objects ending today, so yesterday is the second-to-last; prefer this to computing dates yourself |73| a weekly or monthly digest, deltas, recommendations | `oura-cli report --format json` or `oura-cli report --period month --format json` | `{period, weekStart, weekEnd, days[], completeThrough, lastUpload, averages[], spo2, patterns, sleepDetails, recommendations[]}`; `lastUpload` is the newest heart-rate sample in the cache, ISO 8601 UTC, and lags the daily summaries by days; see below for `recommendations` |74| a trend over N days | `oura-cli db trends 30 --format json` | array of `{label, avg, min, max, count}` |75| how much data there is, personal bests | `oura-cli db stats --format json` | `{tables[], dateRange, trends[], records}` |76| detail no summary shows (workouts, tags, sessions, HRV samples, battery, ring hardware) | `oura-cli db rows <collection> [range] --format json` | array of Oura records as cached |77| the exact record straight from Oura | `oura-cli fetch <collection> [range]` | array of Oura records; always JSON |78| refresh the cache | `oura-cli sync --format json` | `{import: {startDate (the oldest resume day across collections), endDate, fetched, added, removed, dropped, refused, pruned, isFirstSync}, today}`; a non-empty `refused` names rows sync kept because the API stopped returning them, which `oura-cli sync --prune=<collection>` applies once the user agrees, and that run adds `import.pruneScope` (`"all"` or the collections named) |7980A day object from `db today`, `db date` and `db week`:8182```json83{84 "day": "2026-09-01", "partial": false,85 "sleep_score": 84, "readiness_score": 79, "activity_score": 71, "steps": 9412,86 "stress": "normal", "spo2": 97.4, "temp_deviation": -0.1,87 "sleep_hours": 7.3, "deep_hours": 1.6, "rem_hours": 1.5,88 "avg_hrv": 52, "lowest_hr": 48, "efficiency": 9189}90```9192Every field except `day` and `partial` can be `null`. Today is often all `null` until the morning, because Oura publishes a day's summary only after that night's sleep syncs from the ring. A past day that is all `null` is not cached: run `oura-cli db date <day> --format table` once, the one deliberate exception to rule 1: the table output says whether the day is before the cache begins, after it ends, or in a gap, and which `sync` call would fill it, and the JSON does not. Pass that hint on rather than guessing. When every day is `null` in `db week` and `report` as well, the cache is empty: `doctor` says so (`data: warn`, `nextStep: oura-cli sync`). A `--db` path that does not exist is created as a new empty cache rather than rejected, so check the path before concluding there is no data.9394`report.recommendations` holds codes, not sentences: `sleep_low`, `sleep_great`, `readiness_low`, `readiness_great`, `steps_low`, `steps_great`. Phrase them yourself; the table output has one line per code.9596### Collections9798`sleep readiness activity hr spo2 stress workout sleep-periods cv-age resilience vo2max sleep-time session rest-mode tags ring battery`99100Range flags for `db rows` and `fetch`: `--day YYYY-MM-DD` (default: today), `--from YYYY-MM-DD --to YYYY-MM-DD` (both required), or `--days N`. `ring` is a hardware snapshot and takes no range. `hr` and `battery` are timeseries keyed by timestamp: a single day of `hr` is hundreds of rows, so add `--limit N` on `db rows` or narrow the day before reading it into context.101102```bash103oura-cli db rows workout --days 7 --format json # last week's workouts104oura-cli db rows tags --from 2026-08-01 --to 2026-08-31 --format json105oura-cli db rows hr --day 2026-09-01 --limit 50 --format json106oura-cli db rows ring --format json # ring model, size, firmware107oura-cli fetch sleep-periods --day 2026-09-01 # bedtime, HRV, per-period detail108```109110## Reading the numbers111112- Scores are 0–100. Oura's own bands: 85+ optimal, 70–84 good, under 70 pay attention. `report` flags days with sleep or readiness under 70 and activity at 90 or above.113- `partial: true` marks a day whose activity is still accumulating (normally today). Do not compare its activity score or steps with closed days; `report` already leaves such days out of the activity averages and out of the recommendations, and says so in `completeThrough`.114- `temp_deviation` is degrees Celsius against the user's baseline; a sustained rise with a readiness drop is what Oura itself reads as a possible illness signal. Report it, do not diagnose.115- `stress` is Oura's day summary (`restored`, `normal`, `stressful`) or `null`.116- `--tz Europe/Helsinki` (or `OURA_TZ`) sets the timezone `--day` and `today` are interpreted in; default is the system timezone. One quirk: `day` on cached `hr` and `battery` rows is the date in Oura's UTC timestamp, not the local day.117118## Errors and exit codes119120Errors are a single JSON object on **stderr**:121122```json123{"error":{"code":"BAD_ARGS","message":"<day> must be a real YYYY-MM-DD date, got \"tomorrow\".","hint":"Write <day> as a calendar day, e.g. 2026-09-01."}}124```125126`code` and `message` are always present. `hint` is optional: it is there when the CLI has a fix to suggest, and absent otherwise (an unexpected internal error, some argument conflicts), so treat a missing `hint` as no suggestion, not as a malformed payload.127128| Exit | Meaning | Typical code | Usually means |129|---|---|---|---|130| 0 | success (check `.ok` on `doctor` / `healthcheck`) | | |131| 1 | user error | `BAD_ARGS` | fix the arguments |132| 2 | auth | `TOKEN_MISSING`, `TOKEN_INVALID` | user runs `oura-cli login` |133| 3 | API or network | `API_ERROR` | a `429` was already retried for up to three minutes; wait before retrying a long `fetch hr` |134| 4 | local storage | `DB_ERROR` | see `doctor` |135136## Discovering the rest137138- `oura-cli describe` prints a JSON manifest of every command, argument, enum value, exit code and the JSON Schema file for each output. Read it instead of scraping `--help`.139- `oura-cli healthcheck` is a liveness probe: `{ok, version, latencyMs}`.140- Global flags `--format`, `--db`, `--tz`, `--token`, `--no-color` may appear anywhere on the line.141- JSON Schemas for `fetch <collection>`, `doctor` and `describe` ship in the package under `docs/schemas/`.