curl --request POST \
--url https://app.skcal.fit/api/workouts/describe \
--header 'Content-Type: application/json' \
--cookie __Secure-better-auth.session_token= \
--data '
{
"text": "ran 5k easy in 28 minutes, then 3x10 pullups",
"date": "2026-07-02",
"tz": 420
}
'import requests
url = "https://app.skcal.fit/api/workouts/describe"
payload = {
"text": "ran 5k easy in 28 minutes, then 3x10 pullups",
"date": "2026-07-02",
"tz": 420
}
headers = {
"cookie": "__Secure-better-auth.session_token=",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {
cookie: '__Secure-better-auth.session_token=',
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: 'ran 5k easy in 28 minutes, then 3x10 pullups',
date: '2026-07-02',
tz: 420
})
};
fetch('https://app.skcal.fit/api/workouts/describe', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.skcal.fit/api/workouts/describe",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'text' => 'ran 5k easy in 28 minutes, then 3x10 pullups',
'date' => '2026-07-02',
'tz' => 420
]),
CURLOPT_COOKIE => "__Secure-better-auth.session_token=",
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.skcal.fit/api/workouts/describe"
payload := strings.NewReader("{\n \"text\": \"ran 5k easy in 28 minutes, then 3x10 pullups\",\n \"date\": \"2026-07-02\",\n \"tz\": 420\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("cookie", "__Secure-better-auth.session_token=")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://app.skcal.fit/api/workouts/describe")
.header("cookie", "__Secure-better-auth.session_token=")
.header("Content-Type", "application/json")
.body("{\n \"text\": \"ran 5k easy in 28 minutes, then 3x10 pullups\",\n \"date\": \"2026-07-02\",\n \"tz\": 420\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.skcal.fit/api/workouts/describe")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["cookie"] = '__Secure-better-auth.session_token='
request["Content-Type"] = 'application/json'
request.body = "{\n \"text\": \"ran 5k easy in 28 minutes, then 3x10 pullups\",\n \"date\": \"2026-07-02\",\n \"tz\": 420\n}"
response = http.request(request)
puts response.read_body{
"id": "9c2d1e4f-7a8b-4c5d-9e0f-1a2b3c4d5e6f",
"source": "manual",
"activityType": "run",
"summary": "5k run + pullups",
"description": "ran 5k easy, then 3x10 pullups",
"startedAt": 1783100000000,
"durationS": 1680,
"movingDurationS": null,
"distanceM": 5000,
"elevationGainM": null,
"energyKcal": 320,
"avgHr": 148,
"maxHr": null,
"avgPowerW": null,
"avgCadence": null,
"exercises": [
{
"exercise": "pullups",
"sets": 3,
"reps": 10,
"weightLb": null,
"durationS": null,
"distanceM": null,
"notes": null
}
],
"createdAt": 1783100001000
}{
"error": "weightKg must be 9-320 kg",
"detail": "upstream model returned 500"
}{
"error": "weightKg must be 9-320 kg",
"detail": "upstream model returned 500"
}{
"error": "weightKg must be 9-320 kg",
"detail": "upstream model returned 500"
}Log a workout from a description
Parses a freeform description of a workout into a normalized row (activity type, duration, distance, heart rate, strength exercises) and logs it.
curl --request POST \
--url https://app.skcal.fit/api/workouts/describe \
--header 'Content-Type: application/json' \
--cookie __Secure-better-auth.session_token= \
--data '
{
"text": "ran 5k easy in 28 minutes, then 3x10 pullups",
"date": "2026-07-02",
"tz": 420
}
'import requests
url = "https://app.skcal.fit/api/workouts/describe"
payload = {
"text": "ran 5k easy in 28 minutes, then 3x10 pullups",
"date": "2026-07-02",
"tz": 420
}
headers = {
"cookie": "__Secure-better-auth.session_token=",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {
cookie: '__Secure-better-auth.session_token=',
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: 'ran 5k easy in 28 minutes, then 3x10 pullups',
date: '2026-07-02',
tz: 420
})
};
fetch('https://app.skcal.fit/api/workouts/describe', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.skcal.fit/api/workouts/describe",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'text' => 'ran 5k easy in 28 minutes, then 3x10 pullups',
'date' => '2026-07-02',
'tz' => 420
]),
CURLOPT_COOKIE => "__Secure-better-auth.session_token=",
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.skcal.fit/api/workouts/describe"
payload := strings.NewReader("{\n \"text\": \"ran 5k easy in 28 minutes, then 3x10 pullups\",\n \"date\": \"2026-07-02\",\n \"tz\": 420\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("cookie", "__Secure-better-auth.session_token=")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://app.skcal.fit/api/workouts/describe")
.header("cookie", "__Secure-better-auth.session_token=")
.header("Content-Type", "application/json")
.body("{\n \"text\": \"ran 5k easy in 28 minutes, then 3x10 pullups\",\n \"date\": \"2026-07-02\",\n \"tz\": 420\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.skcal.fit/api/workouts/describe")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["cookie"] = '__Secure-better-auth.session_token='
request["Content-Type"] = 'application/json'
request.body = "{\n \"text\": \"ran 5k easy in 28 minutes, then 3x10 pullups\",\n \"date\": \"2026-07-02\",\n \"tz\": 420\n}"
response = http.request(request)
puts response.read_body{
"id": "9c2d1e4f-7a8b-4c5d-9e0f-1a2b3c4d5e6f",
"source": "manual",
"activityType": "run",
"summary": "5k run + pullups",
"description": "ran 5k easy, then 3x10 pullups",
"startedAt": 1783100000000,
"durationS": 1680,
"movingDurationS": null,
"distanceM": 5000,
"elevationGainM": null,
"energyKcal": 320,
"avgHr": 148,
"maxHr": null,
"avgPowerW": null,
"avgCadence": null,
"exercises": [
{
"exercise": "pullups",
"sets": 3,
"reps": 10,
"weightLb": null,
"durationS": null,
"distanceM": null,
"notes": null
}
],
"createdAt": 1783100001000
}{
"error": "weightKg must be 9-320 kg",
"detail": "upstream model returned 500"
}{
"error": "weightKg must be 9-320 kg",
"detail": "upstream model returned 500"
}{
"error": "weightKg must be 9-320 kg",
"detail": "upstream model returned 500"
}Authorizations
Better Auth session. Signing in (Google or email magic link) sets an HttpOnly session cookie that scopes every request to that account. The web app and CLI use this; agents use the OAuth 2.1 flow of the MCP server.
Body
The workout description and optional local day.
Body for logging a workout from a freeform text description.
Response
The workout was parsed and logged.
A logged workout, normalized to the standard fields of Apple Health, Garmin, and Strava; metric fields are null when the user didn't state them.
Workout identifier.
"9c2d1e4f-7a8b-4c5d-9e0f-1a2b3c4d5e6f"
Where the workout came from; manual for described/photographed logs.
manual, apple_health, garmin, strava "manual"
Normalized activity type (run, ride, swim, walk, hike, strength_training, yoga, rowing, elliptical, crossfit, other).
"run"
Short AI-generated title.
"5k run + pullups"
The user's original words (or the summary for photo-only logs).
"ran 5k easy, then 3x10 pullups"
Start time as Unix milliseconds.
1783100000000
Elapsed duration in seconds.
1680
Moving time in seconds, when distinct from elapsed.
null
Distance in meters.
5000
Elevation gain in meters.
null
Active energy in kilocalories.
320
Average heart rate in bpm.
148
Max heart rate in bpm.
null
Average power in watts.
null
Average cadence (spm or rpm as reported).
null
Strength breakdown; empty for pure cardio.
Show child attributes
Show child attributes
[
{
"exercise": "pullups",
"sets": 3,
"reps": 10,
"weightLb": null,
"durationS": null,
"distanceM": null,
"notes": null
}
]
Row creation time as Unix milliseconds.
1783100001000