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.
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 URL | https://api.skillsafe.ai |
|---|---|
| App slug | rhythm-desk |
| Auth | Authorization: Bearer <token> |
| Body | Content-Type: application/json |
| Idempotency | Idempotency-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 & path | Cost | What it does |
|---|---|---|
POST /v1/app-api/guest | free | Mints a guest token for a slug. |
GET /v1/app-api/me | free | Returns subject_type, subject_id, credits. Nothing else. |
POST /v1/app-api/estimate | free | Returns the hold and minimum for a specific input. |
POST /v1/app-api/run | metered | Runs a lane and returns a job. |
POST /v1/app-api/run-stream | metered | Same, as SSE, with delta frames as the reply generates. |
GET /v1/app-api/jobs/{id} | free | Polls 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.
| Field | Type | Meaning |
|---|---|---|
task | string | "diagnostics" or "brief". Required. |
purpose | string | One of training-recovery, stress-research, wellness-tracking, clinical-prescreen, other. Governs the language and the kind of action recommended, never the arithmetic. |
prescan | object | The computed facts table. Required. See below. |
prior_diagnostics | object | Optional, 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_note | string | Optional. 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 id | Raised when |
|---|---|
FLATLINE | A run of ≥1.0 s of identical consecutive values — a disconnected lead or a saturated sensor. |
CLIPPING | The signal is pinned at its own min or max for ≥0.5 s. |
IRREGULAR_SAMPLING | Sample-to-sample gaps vary by more than 15% (coefficient of variation). |
TOO_FEW_BEATS | Fewer than 4 beats detected — no HRV is computable. |
IMPLAUSIBLE_HR | An RR interval implying a rate outside 30–220 bpm. |
SHORT_DURATION | Under 60 s, the usual floor for stable time-domain HRV. |
TRUNCATED_FOR_SIZE | The paste was cut before parsing. |
ASSUMED_FIRST_COLUMN_IS_TIME | No 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 }
import requests
API = "https://api.skillsafe.ai"
r = requests.post(f"{API}/v1/app-api/guest", json={"slug": "rhythm-desk"})
r.raise_for_status()
token = r.json()["data"]["token"]
me = requests.get(f"{API}/v1/app-api/me",
headers={"Authorization": f"Bearer {token}"}).json()["data"]
print(me["subject_type"], me["credits"])
const API = "https://api.skillsafe.ai";
const g = await fetch(`${API}/v1/app-api/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "rhythm-desk" }),
});
const { data: { token } } = await g.json();
const me = await fetch(`${API}/v1/app-api/me`, {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());
console.log(me.data.subject_type, me.data.credits);
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
const API = "https://api.skillsafe.ai"
func main() {
body, _ := json.Marshal(map[string]string{"slug": "rhythm-desk"})
res, err := http.Post(API+"/v1/app-api/guest", "application/json", bytes.NewReader(body))
if err != nil {
panic(err)
}
defer res.Body.Close()
var out struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&out)
req, _ := http.NewRequest("GET", API+"/v1/app-api/me", nil)
req.Header.Set("Authorization", "Bearer "+out.Data.Token)
me, _ := http.DefaultClient.Do(req)
defer me.Body.Close()
var who map[string]any
json.NewDecoder(me.Body).Decode(&who)
fmt.Println(who["data"])
}
import java.net.URI;
import java.net.http.*;
var api = "https://api.skillsafe.ai";
var http = HttpClient.newHttpClient();
var guest = http.send(HttpRequest.newBuilder()
.uri(URI.create(api + "/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"rhythm-desk\"}"))
.build(),
HttpResponse.BodyHandlers.ofString());
// use your JSON library of choice to pull data.token out of guest.body()
String token = extractToken(guest.body());
var me = http.send(HttpRequest.newBuilder()
.uri(URI.create(api + "/v1/app-api/me"))
.header("Authorization", "Bearer " + token)
.GET().build(),
HttpResponse.BodyHandlers.ofString());
System.out.println(me.body());
require "net/http"
require "json"
API = URI("https://api.skillsafe.ai")
guest = Net::HTTP.post(URI("#{API}/v1/app-api/guest"),
{ slug: "rhythm-desk" }.to_json,
"Content-Type" => "application/json")
token = JSON.parse(guest.body)["data"]["token"]
req = Net::HTTP::Get.new(URI("#{API}/v1/app-api/me"))
req["Authorization"] = "Bearer #{token}"
me = Net::HTTP.start(API.host, API.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(me.body)["data"]
<?php
$api = "https://api.skillsafe.ai";
$guest = json_decode(file_get_contents("$api/v1/app-api/guest", false,
stream_context_create(["http" => [
"method" => "POST",
"header" => "Content-Type: application/json",
"content" => json_encode(["slug" => "rhythm-desk"]),
]])), true);
$token = $guest["data"]["token"];
$me = json_decode(file_get_contents("$api/v1/app-api/me", false,
stream_context_create(["http" => [
"header" => "Authorization: Bearer $token",
]])), true);
echo $me["data"]["subject_type"], " ", $me["data"]["credits"], "\n";
using System.Net.Http.Json;
using System.Text.Json;
const string Api = "https://api.skillsafe.ai";
using var http = new HttpClient();
var guest = await http.PostAsJsonAsync($"{Api}/v1/app-api/guest",
new { slug = "rhythm-desk" });
var token = (await guest.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("data").GetProperty("token").GetString();
http.DefaultRequestHeaders.Authorization = new("Bearer", token);
var me = await http.GetFromJsonAsync<JsonElement>($"{Api}/v1/app-api/me");
Console.WriteLine(me.GetProperty("data"));
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'
import json, requests
API, TOKEN = "https://api.skillsafe.ai", "…"
H = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
payload = {
"task": "diagnostics",
"purpose": "training-recovery",
"prescan": prescan, # the facts table you computed
}
est = requests.post(f"{API}/v1/app-api/estimate", headers=H, json=payload).json()["data"]
print(est["hold_credits"], est["min_credits"])
r = requests.post(f"{API}/v1/app-api/run", json=payload,
headers={**H, "Idempotency-Key": "rhythm-desk-ecg-a1"})
r.raise_for_status()
data = r.json()["data"]
# output.output is a JSON *string* - parse it a second time
result = json.loads(data["output"]["output"])
print(result["verdict"], "-", result["title"])
for f in result["findings"]:
print(f' {f["id"]} [{f["severity"]}] {f["title"]}')
const API = "https://api.skillsafe.ai";
const TOKEN = "…";
const H = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };
const payload = {
task: "diagnostics",
purpose: "training-recovery",
prescan, // the facts table you computed
};
const est = await fetch(`${API}/v1/app-api/estimate`, {
method: "POST", headers: H, body: JSON.stringify(payload),
}).then((r) => r.json());
console.log(est.data.hold_credits, est.data.min_credits);
const run = await fetch(`${API}/v1/app-api/run`, {
method: "POST",
headers: { ...H, "Idempotency-Key": "rhythm-desk-ecg-a1" },
body: JSON.stringify(payload),
}).then((r) => r.json());
// output.output is a JSON *string* - parse it a second time
const result = JSON.parse(run.data.output.output);
console.log(result.verdict, "-", result.title);
payload, _ := json.Marshal(map[string]any{
"task": "diagnostics",
"purpose": "training-recovery",
"prescan": prescan, // the facts table you computed
})
req, _ := http.NewRequest("POST", API+"/v1/app-api/run", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "rhythm-desk-ecg-a1")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var env struct {
Data struct {
Output struct {
Output string `json:"output"`
} `json:"output"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
// the inner Output is a JSON string - decode it a second time
var result map[string]any
json.Unmarshal([]byte(env.Data.Output.Output), &result)
fmt.Println(result["verdict"], result["title"])
var payload = """
{"task":"diagnostics","purpose":"training-recovery","prescan":%s}
""".formatted(prescanJson);
var run = http.send(HttpRequest.newBuilder()
.uri(URI.create(api + "/v1/app-api/run"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "rhythm-desk-ecg-a1")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build(),
HttpResponse.BodyHandlers.ofString());
// data.output.output is a JSON string - parse it, then parse that
String inner = readPath(run.body(), "data", "output", "output");
var result = parseJson(inner);
System.out.println(result.get("verdict") + " - " + result.get("title"));
payload = {
task: "diagnostics",
purpose: "training-recovery",
prescan: prescan # the facts table you computed
}.to_json
req = Net::HTTP::Post.new(URI("#{API}/v1/app-api/run"))
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "rhythm-desk-ecg-a1"
req.body = payload
res = Net::HTTP.start(API.host, API.port, use_ssl: true) { |h| h.request(req) }
# output.output is a JSON string - parse it a second time
result = JSON.parse(JSON.parse(res.body)["data"]["output"]["output"])
puts "#{result["verdict"]} - #{result["title"]}"
<?php
$payload = json_encode([
"task" => "diagnostics",
"purpose" => "training-recovery",
"prescan" => $prescan, // the facts table you computed
]);
$res = file_get_contents("$api/v1/app-api/run", false,
stream_context_create(["http" => [
"method" => "POST",
"header" => "Authorization: Bearer $token\r\n"
. "Content-Type: application/json\r\n"
. "Idempotency-Key: rhythm-desk-ecg-a1\r\n",
"content" => $payload,
]]));
// output.output is a JSON string - decode it a second time
$env = json_decode($res, true);
$result = json_decode($env["data"]["output"]["output"], true);
echo $result["verdict"], " - ", $result["title"], "\n";
var payload = new
{
task = "diagnostics",
purpose = "training-recovery",
prescan, // the facts table you computed
};
var req = new HttpRequestMessage(HttpMethod.Post, $"{Api}/v1/app-api/run")
{
Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "rhythm-desk-ecg-a1");
var res = await http.SendAsync(req);
var env = await res.Content.ReadFromJsonAsync<JsonElement>();
// output.output is a JSON string - parse it a second time
var inner = env.GetProperty("data").GetProperty("output")
.GetProperty("output").GetString();
var result = JsonDocument.Parse(inner!).RootElement;
Console.WriteLine($"{result.GetProperty("verdict")} - {result.GetProperty("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
- 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.
-
Parse the output twice.
data.outputis an object; the JSON you want is the string atdata.output.output. Treating the object as text yields[object Object]and a parse error that points nowhere near the cause. - 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.
- 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.
- 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.