← Rhythm Desk / API
Get a token

Driving Rhythm Desk from your own code

Rhythm Desk is two lanes over one heartbeat trace: diagnostics reads the rhythm, and brief turns that read into a short decision brief for a stated purpose. Both are reachable over HTTP with a token you can mint in about two seconds. The web page is a client of this same API and has no privileged path.

The free engine runs in the browser, not on the server.

This is the one thing that will surprise you. Beat detection, heart rate, HRV and the signal-quality flags are computed by rhythmlib.js in the page, and the raw waveform is never sent anywhere. The API therefore does not accept a waveform and will not compute one for you: the prescan object in the request body is the measurement, and you are responsible for producing it. Sending a trace instead of a prescan gets you a reply written about nothing.

If you want the browser's exact arithmetic, rhythmlib.js is served from this origin and has no dependencies — see Reusing the engine below.

Base URL and headers

Base URLhttps://api.skillsafe.ai
App slugrhythm-desk
AuthAuthorization: Bearer <token>
BodyContent-Type: application/json
IdempotencyIdempotency-Key: <your key> on /run and /run-stream

There is no X-App-Slug header. The slug is bound to the token when the token is minted, which is why /guest is the only call that names it.

Endpoints

Method & pathCostWhat it does
POST /v1/app-api/guestfreeMints a guest token for a slug.
GET /v1/app-api/mefreeReturns subject_type, subject_id, credits. Nothing else.
POST /v1/app-api/estimatefreeReturns the hold and minimum for a specific input.
POST /v1/app-api/runmeteredRuns a lane and returns a job.
POST /v1/app-api/run-streammeteredSame, as SSE, with delta frames as the reply generates.
GET /v1/app-api/jobs/{id}freePolls a job to a terminal state.

The response envelope

Every non-streaming response is wrapped. The interesting part is always under data:

{ "data": { "...": "..." } }

An error replaces it with error, and the HTTP status carries the category — 401 for a bad or missing token, 402 when the balance cannot cover the minimum, 429 for rate limiting.

{ "error": { "code": "insufficient_credits", "message": "balance below minimum" } }

The input object

The request body is the input object. Do not wrap it in an input field — a wrapped body is accepted with a 200 and the model never sees your task, so you get a plausible reply in the wrong lane and no error to explain it.

FieldTypeMeaning
taskstring"diagnostics" or "brief". Required.
purposestringOne of training-recovery, stress-research, wellness-tracking, clinical-prescreen, other. Governs the language and the kind of action recommended, never the arithmetic.
prescanobjectThe computed facts table. Required. See below.
prior_diagnosticsobjectOptional, brief lane only: the diagnostics reply for this same trace, so the two lanes read as one sitting. Carries verdict, key_metrics and a reduced findings list.
reformat_notestringOptional. Set on a retry when the previous reply did not parse; the model is told to fix exactly that.

The prescan object

This is the whole measurement. Every number the model is allowed to cite comes from here, and a claim about a metric that is not present is caught by the page and labelled as ungrounded.

{
  "ok": true,
  "signal_type": "ecg",                  // "ecg" | "ppg"
  "signal_type_source": "declared-matches-header",
  "sample_rate_hz": 125,
  "duration_s": 66,
  "n_samples": 8250,
  "dropped_rows": 0,
  "truncated_for_size": false,
  "beat_count": 67,
  "hr":  { "mean": 61.2, "min": 54.7, "max": 68.8 },
  "hrv": { "mean_rr_ms": 980.7, "min_rr_ms": 872, "max_rr_ms": 1096,
           "sdnn_ms": 50.3, "rmssd_ms": 68.6,
           "pnn50_pct": 47.7, "pnn20_pct": 84.6, "cv_pct": 5.13 },
  "respiration": { "present": true, "mean_bpm": 13.9, "min_bpm": 13.7,
                   "max_bpm": 14.9, "breath_count": 16 },
  "flags": []
}

When the trace could not be read at all, send { "ok": false, "error": "<why>" } and nothing else. The reply will say so rather than inventing a rhythm.

Quality flags, and the rule that governs them

prescan.flags is a list of { id, severity, label }. The model must return exactly one reconciliation entry per flag you send — no more, no fewer — so a flag can never be quietly ignored.

Flag idRaised when
FLATLINEA run of ≥1.0 s of identical consecutive values — a disconnected lead or a saturated sensor.
CLIPPINGThe signal is pinned at its own min or max for ≥0.5 s.
IRREGULAR_SAMPLINGSample-to-sample gaps vary by more than 15% (coefficient of variation).
TOO_FEW_BEATSFewer than 4 beats detected — no HRV is computable.
IMPLAUSIBLE_HRAn RR interval implying a rate outside 30–220 bpm.
SHORT_DURATIONUnder 60 s, the usual floor for stable time-domain HRV.
TRUNCATED_FOR_SIZEThe paste was cut before parsing.
ASSUMED_FIRST_COLUMN_IS_TIMENo recognised time-column header; a role was assumed.

Valid status values are confirmed, noted, set-aside and superseded.

The output contract

The model returns one JSON object and nothing else — no prose, no code fence. It arrives as a string under output.output, so it needs a second parse:

{ "data": { "job_id": "job_…", "status": "succeeded", "charged_credits": 41,
            "output": { "output": "{\"task\":\"diagnostics\", … }" } } }

The envelope is identical for both lanes; only body differs.

{
  "task": "diagnostics",
  "task_inferred": false,
  "title": "one line naming the trace and its headline rhythm fact",
  "verdict": "clear",                      // clear | notable | concerning | insufficient-data
  "summary": "two to four sentences",
  "assumptions": ["…"],
  "open_questions": ["…"],
  "findings": [
    { "id": "RD-001", "severity": "high", // critical | high | medium | low
      "metric": "sdnn_ms",                 // a prescan key, or ""
      "title": "…", "why": "…", "fix": "…" }
  ],
  "reconciliation": [
    { "flag_id": "SHORT_DURATION", "status": "confirmed", "note": "…" }
  ],
  "next_lane": { "lane": "brief", "reason": "…" },
  "body": { }
}

body — diagnostics

{
  "overview": "a paragraph: signal type, duration, beat count, how much the quality supports",
  "key_metrics": [
    { "metric": "mean_hr", "value": "61.2 bpm", "read": "low-normal resting rate" }
  ],
  "quality_note": "one to three sentences on clipping, dropout, irregular sampling"
}

At most 8 key_metrics, ranked by what matters to a first reader. Each metric must be one of mean_hr, min_hr, max_hr, sdnn_ms, rmssd_ms, pnn50_pct, pnn20_pct, cv_pct, mean_rr_ms, min_rr_ms, max_rr_ms, beat_count, duration_s, sample_rate_hz or respiration_rate, and value must match what prescan reported for it.

body — brief

{
  "purpose": "training-recovery",
  "executive_summary": "two to three sentences, the decision-maker's version",
  "priority_actions": [
    { "rank": 1, "action": "what to do first", "rationale": "grounded in a specific prescan fact" }
  ],
  "watch_items": [
    { "item": "what to keep an eye on", "based_on": "which metric justifies it" }
  ],
  "longer_term": ["…"]
}

priority_actions is ranked 1..N, at most 6. next_lane.lane is always "" from this lane — there is no third lane to hand off to.

Step by step

1 · Mint a token and check the balance

API=https://api.skillsafe.ai

TOKEN=$(curl -sS -X POST "$API/v1/app-api/guest" \
  -H 'Content-Type: application/json' \
  -d '{"slug":"rhythm-desk"}' | jq -r '.data.token')

curl -sS "$API/v1/app-api/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
# { "subject_type": "guest", "subject_id": "gst_…", "credits": 0 }

A guest token can call /me and /estimate. Running a lane is metered and needs a personal token — open the token panel in a browser, sign in, and copy it.

2 · Estimate, then run the diagnostics lane

/estimate is free and takes the same body as the run, so price the exact input rather than a guess. It returns hold_credits (reserved up front) and min_credits (below which the run is refused).

API=https://api.skillsafe.ai
TOKEN=…   # personal token from /tokens.html

cat > input.json <<'JSON'
{
  "task": "diagnostics",
  "purpose": "training-recovery",
  "prescan": {
    "ok": true, "signal_type": "ecg", "signal_type_source": "declared-matches-header",
    "sample_rate_hz": 125, "duration_s": 66, "n_samples": 8250,
    "dropped_rows": 0, "truncated_for_size": false, "beat_count": 67,
    "hr":  { "mean": 61.2, "min": 54.7, "max": 68.8 },
    "hrv": { "mean_rr_ms": 980.7, "min_rr_ms": 872, "max_rr_ms": 1096,
             "sdnn_ms": 50.3, "rmssd_ms": 68.6,
             "pnn50_pct": 47.7, "pnn20_pct": 84.6, "cv_pct": 5.13 },
    "respiration": { "present": true, "mean_bpm": 13.9, "breath_count": 16 },
    "flags": []
  }
}
JSON

curl -sS -X POST "$API/v1/app-api/estimate" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d @input.json | jq '.data | {hold_credits, min_credits}'

curl -sS -X POST "$API/v1/app-api/run" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: rhythm-desk-ecg-2026-08-29-a1' \
  -d @input.json | jq -r '.data.output.output' | jq '.verdict, .title'

3 · Stream it instead

POST /v1/app-api/run-stream takes the same body and headers and replies with text/event-stream. Frames are event: delta with {"text": "…"}, then one event: done carrying the same payload the non-streaming call returns. An event: error frame ends the stream instead.

curl -N -X POST "$API/v1/app-api/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: rhythm-desk-ecg-a1' \
  -d @input.json

event: delta
data: {"text":"{\"task\":\"diagn"}

event: delta
data: {"text":"ostics\",\"verdict\":\"clear\""}

event: done
data: {"job_id":"job_…","status":"succeeded","charged_credits":41,"output":{"output":"{…}"}}

Five things worth knowing

  1. Reuse your idempotency key on a retry, not on a new question. The key should be derived from the input — lane, purpose and the trace itself. A network blip then replays the paid result instead of billing twice, while a genuinely different trace never collides. The one case that needs a new key is a reformat retry: the old key already bought a reply, and replaying it returns the same unparseable text.
  2. Parse the output twice. data.output is an object; the JSON you want is the string at data.output.output. Treating the object as text yields [object Object] and a parse error that points nowhere near the cause.
  3. Check the reconciliation before trusting the read. Every flag you sent should come back exactly once. A missing entry means the model did not account for a quality problem you already know about — the web app marks that case in red, and so should you.
  4. A cited metric outside the prescan vocabulary is not grounded. The list in the output contract is exhaustive. Anything else is the model's own words, however confident it sounds.
  5. This is not a medical device. It describes the arithmetic of a waveform from a single channel of unknown sensor quality. It is not an ECG interpretation, not a diagnosis, and not a substitute for a clinician reading the actual device output.

Reusing the engine

rhythmlib.js is served from this origin, has no dependencies, and exports analyze(text, opts) and factsFor(scan) on window.RhythmLib. analyze parses a delimited table, detects beats and computes the metrics; factsFor reduces the result to the exact prescan object this API expects.

<script src="https://rhythm-desk.skillsafe.ai/rhythmlib.js"></script>
<script>
  var scan = window.RhythmLib.analyze(csvText, { signalType: "ecg", sampleRateHz: 125 });
  if (scan.ok) {
    var prescan = window.RhythmLib.factsFor(scan);   // send this, never the waveform
  }
</script>

It is MIT-spirited derived work inspired by the published agent skill @k-dense-ai/neurokit2; it does not run that project's Python, and is not affiliated with or endorsed by it.