Workout Calendar — API & tutorial Open the app

Put a training plan on a real calendar from your own code

The coach behind Workout Calendar is reachable through the SkillSafe App API — plain JSON over HTTPS with optional streaming. Send the hours someone can train, what is already in their calendar and what they are training for; get back a periodised plan in which every session has a date, a start time and a length that fit those hours. A second lane re-plans the rest of a plan from any date, keeping what was done and dropping what was missed. Examples in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api. Authenticated requests send Authorization: Bearer <token>, and JSON bodies go with Content-Type: application/json. No other app header is needed — the token already knows which app it belongs to. Every response is wrapped in an envelope: {"ok": true, "data": …} on success and {"ok": false, "error": {"code", "message"}} on failure. Runs are billed in SkillSafe credits to the calling token: a worst-case hold is placed up front and the actual cost is settled when the job finishes.

StatusMeaning
400The body is not valid — for example an object nested inside the input, or a missing field. Read error.message.
401Missing or expired token — get a new one (step 1).
402Not enough credits for the hold — top up at skillsafe.ai/account/billing.
403The token is not allowed to do this (for example a guest past the app's sponsored budget).
404Unknown job id.
5xxTransient platform error — retry with backoff, reusing the same Idempotency-Key.

Browsers enforce CORS for this API, so run these examples from a server, a script or a terminal — not from another website's frontend. The week grid, the .ics import, the free template plan and the conflict checker on the app's page all run in the browser with no endpoint behind them; the API surface is the coach run documented below.

Step 0 — A tiny client

Every step below is one or two HTTP calls, so start with a small helper that adds the auth header, sends JSON and unwraps the envelope (it raises when ok is false). The later steps reuse this helper.

export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-d '{json}']
# jq pulls fields out of the {"ok": true, "data": ...} envelope
import json, os, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ["SKILLSAFE_TOKEN"]  # see step 1

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok or not payload.get("ok"):
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // paste your token here (see step 1); keep it out of source control

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok || !json.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		OK    bool            `json:"ok"`
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 || !env.OK {
		msg := res.Status
		if env.Error != nil {
			msg = env.Error.Message
		}
		return fmt.Errorf("api %s %s: %s", method, path, msg)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson...)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"ok": true, "data": ...}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV["SKILLSAFE_TOKEN"] # see step 1

def api(method, path, body = nil, headers = {})
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  headers.each { |k, v| req[k] = v }
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless payload["ok"]
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null, array $headers = []): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => array_merge([
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ], $headers),
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400 || empty($payload["ok"])) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    public const string Api = "https://api.skillsafe.ai/v1/app-api";
    public static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path,
        object? body = null, string? idempotencyKey = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        if (idempotencyKey != null) req.Headers.Add("Idempotency-Key", idempotencyKey);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode || !json.GetProperty("ok").GetBoolean())
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

For scripted use, the simplest reliable path is your personal token: open the token page, sign in, and copy the shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password — it can spend your credits. For fully headless scripts, POST /guest mints a guest token with no browser and no auth header: send {"slug": "workout-calendar"} and get back 201 with {token, guest_id, expires_at}. Guests can always call /me and /estimate, but whether a guest can afford an actual run depends on the app's daily sponsorship budget, so don't build on it.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"workout-calendar"}' | jq -r '.data.token'
# {"ok":true,"data":{"token":"...","guest_id":"...","expires_at":"..."}}
# no Authorization header is needed for /guest
res = requests.post(API + "/guest", json={"slug": "workout-calendar"})
guest = res.json()["data"]          # status 201
token = guest["token"]              # also guest["guest_id"], guest["expires_at"]
// no Authorization header is needed for /guest
const res = await fetch(API + "/guest", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "workout-calendar" }),
});
const { token, guest_id, expires_at } = (await res.json()).data; // status 201
// call() sends the (possibly empty) bearer token; /guest ignores it
var guest struct {
	Token     string `json:"token"`
	GuestID   string `json:"guest_id"`
	ExpiresAt string `json:"expires_at"`
}
err := call("POST", "/guest", map[string]string{"slug": "workout-calendar"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"workout-calendar"}""");
// 201: the token is at data.token (also data.guest_id, data.expires_at)
guest = api("POST", "/guest", { slug: "workout-calendar" })
token = guest["token"] # also guest["guest_id"], guest["expires_at"]
$guest = api("POST", "/guest", ["slug" => "workout-calendar"]);
$token = $guest["token"]; // also guest_id, expires_at
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "workout-calendar" });
var token = guest.GetProperty("token").GetString();

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check it before a run.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["subject_id"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.subject_id, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Build the input

The input is one flat JSON object whose values are all strings or numbers — no nested objects, no arrays. That object is the request body for /estimate, /run and /run-stream: send it as it is, never wrapped in another object under an input key. Multi-line values (commitments, facts, progress) are single strings with \n between lines.

task — pick the lane first

taskWhat it doesExtra fields
"plan"Builds a new plan from start_date to the end of the window. Phases cover the whole window; ## Adjustments is the single bullet - None.Send progress and replan_from as "".
"replan"Re-plans the rest of an existing plan from replan_from. Done and missed sessions before that date stay as they are and are not repeated; missed sessions are not crammed back in, and if 30% or more of recent sessions were missed the next two weeks get lighter. ## Adjustments has 2–6 bullets naming what changed and why.progress and replan_from are required.

A missing or unrecognised task is treated as plan.

The other fields

FieldTypeNotes
goalstringWhat they are training for, e.g. Half marathon, 10K, Get stronger, General fitness.
goal_detailstringThe person's own words: current fitness, a recent time, what they want. Paces are only given when this states a current time or pace.
levelstringbeginner, intermediate or advanced.
intensitystringgentle, steady or ambitious.
equipmentstringWhat they have, e.g. Home - dumbbells and bands. No barbell work without a gym.
limitsstringInjuries, niggles, things to avoid. Can be "".
start_datestringFirst day of the plan, YYYY-MM-DD.
event_datestringRace or test day, YYYY-MM-DD, or "" for none. With a date, exactly one race session lands on it.
weeksnumberCalendar weeks (Monday to Sunday) in the plan window.
sessions_per_weeknumberThe most sessions in any one calendar week, 2–7.
max_minutesnumberThe longest single session (the race itself may exceed it).
availabilitystringFree hours per weekday, e.g. Mon 06:00-07:30, 18:00-21:00; Sat 07:00-12:00. A weekday that is not listed has no free time.
commitmentsstringOne per line: 2026-11-05 08:00-18:00 Conference, 2026-11-21 all day Wedding, or a weekly series every Tue 18:30-20:00 Choir (8 dates, 6 Oct - 24 Nov). Send only those that touch a free hour. Can be "".
factsstringComputed facts, one per line (plan window, free minutes per week, longest window per weekday, commitment counts). The coach treats them as true. May be "".
progressstringReplan only. A record line, then recent sessions as - DATE | START | MIN | CATEGORY | TITLE | done|missed|planned. "" for plan.
replan_fromstringReplan only: the first date that may be scheduled, YYYY-MM-DD. "" for plan.
retry_notestring, optionalFeedback about a previous malformed reply; the coach follows it exactly. Omit it on a first run.

Worked example — "task": "plan"

An intermediate runner (10K in 52 minutes) wants a half marathon under 2 hours on 2026-12-13: four sessions a week across 10 weeks from 2026-10-05, at most 120 minutes each, around choir on Tuesdays, five-a-side on Thursdays, a two-day conference and a wedding. Save it as plan-input.json:

{
  "task": "plan",
  "goal": "Half marathon",
  "goal_detail": "Ran a 10K in 52 minutes in June. Want to finish the half under 2 hours. I like long runs on weekend mornings.",
  "level": "intermediate",
  "intensity": "steady",
  "equipment": "Home - dumbbells and bands",
  "limits": "Left knee niggles on long downhill runs.",
  "start_date": "2026-10-05",
  "event_date": "2026-12-13",
  "weeks": 10,
  "sessions_per_week": 4,
  "max_minutes": 120,
  "availability": "Mon 06:00-07:30; Tue 06:00-07:30, 18:00-21:00; Wed 06:00-07:30; Thu 18:00-21:00; Fri 06:00-07:00; Sat 07:00-12:00; Sun 08:00-11:00",
  "commitments": "every Tue 18:30-20:00 Choir rehearsal (10 dates, 6 Oct - 8 Dec)\nevery Thu 19:00-20:00 5-a-side football (8 dates, 8 Oct - 26 Nov)\n2026-11-05 06:00-21:00 Conference (Leeds)\n2026-11-06 06:00-21:00 Conference (Leeds)\n2026-11-21 all day Sam and Alex's wedding",
  "facts": "plan window: 2026-10-05 to 2026-12-13 (10 calendar weeks, Monday-based; week 1 starts 2026-10-05)\nevent day: 2026-12-13 (Sunday)\npainted free time: 19.5 h/week\nlongest painted window per weekday (min): Mon 90, Tue 180, Wed 90, Thu 180, Fri 60, Sat 300, Sun 180\nfree minutes per calendar week after commitments: w1 1020, w2 1020, w3 1020, w4 1020, w5 840, w6 1020, w7 720, w8 1020, w9 1080, w10 1080\ncommitments: 23 on the calendar, 21 touch a painted hour, 5 lines sent",
  "progress": "",
  "replan_from": ""
}

Worked example — "task": "replan"

Three weeks in, the same runner did 10 sessions and missed 2 (the threshold blocks and a strength session in the week of 19 Oct). Same fields, plus the record in progress (record: 10 done, 2 missed, 0 unmarked before 2026-10-26, then one line per session such as - 2026-10-20 | 06:00 | 65 | quality | Threshold blocks | missed) and replan_from set to the next Monday. Save it as replan-input.json:

{
  "task": "replan",
  "goal": "Half marathon",
  "goal_detail": "Ran a 10K in 52 minutes in June. Want to finish the half under 2 hours. I like long runs on weekend mornings.",
  "level": "intermediate",
  "intensity": "steady",
  "equipment": "Home - dumbbells and bands",
  "limits": "Left knee niggles on long downhill runs.",
  "start_date": "2026-10-05",
  "event_date": "2026-12-13",
  "weeks": 10,
  "sessions_per_week": 4,
  "max_minutes": 120,
  "availability": "Mon 06:00-07:30; Tue 06:00-07:30, 18:00-21:00; Wed 06:00-07:30; Thu 18:00-21:00; Fri 06:00-07:00; Sat 07:00-12:00; Sun 08:00-11:00",
  "commitments": "every Tue 18:30-20:00 Choir rehearsal (10 dates, 6 Oct - 8 Dec)\nevery Thu 19:00-20:00 5-a-side football (8 dates, 8 Oct - 26 Nov)\n2026-11-05 06:00-21:00 Conference (Leeds)\n2026-11-06 06:00-21:00 Conference (Leeds)\n2026-11-21 all day Sam and Alex's wedding",
  "facts": "plan window: 2026-10-05 to 2026-12-13 (10 calendar weeks, Monday-based; week 1 starts 2026-10-05)\nevent day: 2026-12-13 (Sunday)\npainted free time: 19.5 h/week\nlongest painted window per weekday (min): Mon 90, Tue 180, Wed 90, Thu 180, Fri 60, Sat 300, Sun 180\nfree minutes per calendar week after commitments: w1 1020, w2 1020, w3 1020, w4 1020, w5 840, w6 1020, w7 720, w8 1020, w9 1080, w10 1080\ncommitments: 23 on the calendar, 21 touch a painted hour, 5 lines sent",
  "progress": "record: 10 done, 2 missed, 0 unmarked before 2026-10-26\ncurrently planned from 2026-10-26: 28 sessions\n- 2026-10-06 | 06:00 | 60 | quality | Tempo intro | done\n- 2026-10-07 | 06:00 | 30 | strength | Runner strength A | done\n- 2026-10-09 | 06:00 | 45 | easy | Easy run | done\n- 2026-10-10 | 07:30 | 70 | long | Long run | done\n- 2026-10-13 | 06:00 | 60 | quality | Cruise intervals | done\n- 2026-10-14 | 06:00 | 30 | strength | Runner strength B | done\n- 2026-10-16 | 06:00 | 50 | easy | Easy run | done\n- 2026-10-17 | 07:30 | 80 | long | Long run | done\n- 2026-10-20 | 06:00 | 65 | quality | Threshold blocks | missed\n- 2026-10-21 | 06:00 | 35 | strength | Runner strength A | missed\n- 2026-10-23 | 06:00 | 50 | easy | Easy run | done\n- 2026-10-24 | 07:30 | 85 | long | Long run | done",
  "replan_from": "2026-10-26"
}

Load either file and use the object as the body, unchanged:

# the file IS the body - no wrapper object
jq -e '.task' plan-input.json      # "plan"
jq -e '.task' replan-input.json    # "replan"
with open("plan-input.json") as f:
    payload = json.load(f)          # {"task": "plan", "goal": ..., ...}

# every value must be a string or a number
assert all(isinstance(v, (str, int, float)) for v in payload.values())
import { readFile } from "node:fs/promises";

const payload = JSON.parse(await readFile("plan-input.json", "utf8"));
// every value must be a string or a number
for (const [k, v] of Object.entries(payload))
  if (typeof v !== "string" && typeof v !== "number") throw new Error(`${k} is not scalar`);
raw, err := os.ReadFile("plan-input.json")
if err != nil {
	log.Fatal(err)
}
var payload map[string]any // strings and numbers only
if err := json.Unmarshal(raw, &payload); err != nil {
	log.Fatal(err)
}
// the file content is sent verbatim as the request body
String payloadJson = java.nio.file.Files.readString(java.nio.file.Path.of("plan-input.json"));
payload = JSON.parse(File.read("plan-input.json"))
# every value must be a string or a number
raise "nested value" unless payload.values.all? { |v| v.is_a?(String) || v.is_a?(Numeric) }
$payload = json_decode(file_get_contents("plan-input.json"), true);
// every value must be a string or a number
foreach ($payload as $k => $v) {
    if (!is_string($v) && !is_int($v) && !is_float($v)) throw new Exception("$k is not scalar");
}
var payloadJson = await File.ReadAllTextAsync("plan-input.json");
var payload = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(payloadJson)!;

Step 4 — Estimate the cost

POST /estimate

Send the same body you would send to a run. Nothing is charged and no job is created. The response carries hold_credits (the worst-case hold a run would place), model and model_alias (the resolved model), markup_bps, input_checked (whether the body passed validation) and warnings. The app refuses to start a run when hold_credits exceeds the caller's balance — a sensible check for your scripts too.

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d @plan-input.json | jq '.data | {hold_credits, model, model_alias, input_checked, warnings}'
est = api("POST", "/estimate", payload)
print("worst case:", est["hold_credits"], "credits on", est["model"])
if est.get("warnings"):
    print("warnings:", est["warnings"])
if est["hold_credits"] > api("GET", "/me")["credits"]:
    raise SystemExit("not enough credits for this run")
const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits, "credits on", est.model, est.warnings ?? []);
const me = await api("GET", "/me");
if (est.hold_credits > me.credits) throw new Error("not enough credits for this run");
var est struct {
	HoldCredits  int64  `json:"hold_credits"`
	Model        string `json:"model"`
	ModelAlias   string `json:"model_alias"`
	MarkupBps    int64  `json:"markup_bps"`
	InputChecked bool   `json:"input_checked"`
	Warnings     []any  `json:"warnings"`
}
err := call("POST", "/estimate", payload, &est)
String envelope = api("POST", "/estimate", payloadJson);
// worst-case cost is at data.hold_credits; also data.model, data.warnings
est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"]} credits on #{est["model"]}"
puts "warnings: #{est["warnings"]}" unless Array(est["warnings"]).empty?
$est = api("POST", "/estimate", $payload);
echo "worst case: {$est['hold_credits']} credits on {$est['model']}\n";
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits on {est.GetProperty("model")}");

Step 5 — Run it and wait

POST /run
GET /jobs/{job_id}

/run places the credit hold and returns a job_id; poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed. Send an Idempotency-Key header (recommended) so a network retry can't start a second, double-charged run — reuse the key when retrying the same attempt, use a new one for a new attempt. The coach replies with plain text in a fixed shape (step 7). It arrives in the job's output: either the text itself or an object whose output field holds it, so read both.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: wc-plan-$(date +%s)" \
  -d @plan-input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

# the plan is plain text in data.output (a string, or an object with .output)
echo "$JOB" | jq -r '.data.output | if type == "object" then .output else . end' > plan.txt
import time, uuid

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": f"wc-plan-{uuid.uuid4()}"})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
text = raw.get("output", "") if isinstance(raw, dict) else raw  # plain text, not JSON
print(text.splitlines()[0])   # "LANE: plan"
const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": `wc-plan-${crypto.randomUUID()}` });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const out = job.output;
const text = typeof out === "string" ? out : out?.output ?? ""; // plain text, not JSON
console.log(text.split("\n")[0]);                               // "LANE: plan"
// add the header in call() for production code; shown inline here
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status string          `json:"status"`
	Error  any             `json:"error"`
	Output json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}

var text string // output is a string, or {"output": "..."}
if json.Unmarshal(job.Output, &text) != nil {
	var obj struct{ Output string `json:"output"` }
	json.Unmarshal(job.Output, &obj)
	text = obj.Output
}
fmt.Println(strings.SplitN(text, "\n", 2)[0]) // "LANE: plan"
// add .header("Idempotency-Key", "wc-plan-" + java.util.UUID.randomUUID()) in api()
String envelope = api("POST", "/run", payloadJson);
String jobId = /* data.job_id via your JSON library */;

String job;
while (true) {
    job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// the plan is the plain-text string at data.output (or data.output.output
// when output is an object) - no second JSON parse, just read the string
require "securerandom"

started = api("POST", "/run", payload, { "Idempotency-Key" => "wc-plan-#{SecureRandom.uuid}" })

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed").to_s if job["status"] == "failed"

raw = job["output"]
text = raw.is_a?(Hash) ? raw["output"].to_s : raw.to_s  # plain text, not JSON
puts text.lines.first                                  # "LANE: plan"
$started = api("POST", "/run", $payload, ["Idempotency-Key: wc-plan-" . bin2hex(random_bytes(8))]);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));

if ($job["status"] === "failed") {
    throw new Exception(json_encode($job["error"] ?? "run failed"));
}

$raw = $job["output"];
$text = is_array($raw) ? ($raw["output"] ?? "") : $raw; // plain text, not JSON
echo strtok($text, "\n") . "\n";                       // "LANE: plan"
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload,
    idempotencyKey: $"wc-plan-{Guid.NewGuid()}");
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}
var outEl = job.GetProperty("output");
var text = outEl.ValueKind == JsonValueKind.String
    ? outEl.GetString()!
    : outEl.GetProperty("output").GetString()!;
Console.WriteLine(text.Split('\n')[0]); // "LANE: plan"

Step 6 — The same run, streamed

POST /run-stream

Identical body to /run (and the same Idempotency-Key advice), but the response is text/event-stream. Events:

EventData
job{job_id} — the run was accepted.
delta{text} — the next chunk of coach output, when the server sends chunks.
tickA heartbeat that keeps the connection open while the model works. Ignore it (or use it to show that the run is alive).
doneThe finished job, carrying the whole output in output (a string, or an object with output). Authoritative — always read the plan from here, not from concatenated deltas.
error{code, message} — the run failed.
curl -sN -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: wc-plan-$(date +%s)" \
  -d @plan-input.json
# event: job    data: {"job_id":"..."}
# event: tick   data: {...}
# event: delta  data: {"text":"LANE: plan\nPLAN: "}
# ...
# event: done   data: {"job_id":"...","status":"succeeded","output":{"output":"LANE: plan\n..."}}
res = requests.post(API + "/run-stream", json=payload, stream=True,
                    headers={"Authorization": f"Bearer {TOKEN}",
                             "Idempotency-Key": f"wc-plan-{uuid.uuid4()}"})
event, done = None, None
for line in res.iter_lines(decode_unicode=True):
    if line.startswith("event:"):
        event = line[6:].strip()
    elif line.startswith("data:"):
        data = json.loads(line[5:])
        if event == "delta":
            print(data.get("text", ""), end="", flush=True)
        elif event == "done":
            done = data
        elif event == "error":
            raise RuntimeError(data.get("message"))
        # "tick" is a heartbeat - nothing to do

out = done["output"]
text = out.get("output", "") if isinstance(out, dict) else out  # authoritative
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json",
             "Idempotency-Key": `wc-plan-${crypto.randomUUID()}` },
  body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", event = "message", done;
for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  let i;
  while ((i = buf.indexOf("\n")) >= 0) {
    const line = buf.slice(0, i); buf = buf.slice(i + 1);
    if (line.startsWith("event:")) event = line.slice(6).trim();
    else if (line.startsWith("data:")) {
      const data = JSON.parse(line.slice(5));
      if (event === "delta") console.log(data.text ?? "");
      else if (event === "done") done = data;
      else if (event === "error") throw new Error(data.message);
      // "tick" is a heartbeat - nothing to do
    }
  }
}
const text = typeof done.output === "string" ? done.output : done.output.output; // authoritative
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", fmt.Sprintf("wc-plan-%d", time.Now().UnixNano()))
res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 1<<20), 1<<20)
event, done := "", []byte(nil)
for sc.Scan() {
	line := sc.Text()
	if strings.HasPrefix(line, "event:") {
		event = strings.TrimSpace(line[6:])
	} else if strings.HasPrefix(line, "data:") {
		data := strings.TrimSpace(line[5:])
		switch event {
		case "done":
			done = []byte(data)
		case "error":
			log.Fatal(data)
		} // "delta": optional live text; "tick": heartbeat
	}
}
// unmarshal done, then read .output exactly as in step 5
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "wc-plan-" + java.util.UUID.randomUUID())
    .POST(HttpRequest.BodyPublishers.ofString(payloadJson))
    .build();
var lines = HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body();

final String[] event = {""};
StringBuilder doneData = new StringBuilder();
lines.forEach(line -> {
    if (line.startsWith("event:")) event[0] = line.substring(6).trim();
    else if (line.startsWith("data:")) {
        if (event[0].equals("done")) doneData.append(line.substring(5).trim());
        else if (event[0].equals("error")) throw new RuntimeException(line);
        // "delta": optional live text; "tick": heartbeat
    }
});
// parse doneData, then read output (string) or output.output
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "wc-plan-#{SecureRandom.uuid}"
req.body = payload.to_json

event, done, buf = nil, nil, +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      buf << chunk
      while (i = buf.index("\n"))
        line = buf.slice!(0..i).chomp
        if line.start_with?("event:") then event = line[6..].strip
        elsif line.start_with?("data:")
          data = JSON.parse(line[5..])
          print data["text"] if event == "delta"
          done = data if event == "done"
          raise data["message"] if event == "error"
        end
      end
    end
  end
end
out = done["output"]
text = out.is_a?(Hash) ? out["output"] : out  # authoritative
$event = ""; $done = null; $buf = "";
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: wc-plan-" . bin2hex(random_bytes(8)),
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done, &$buf) {
        $buf .= $chunk;
        while (($i = strpos($buf, "\n")) !== false) {
            $line = rtrim(substr($buf, 0, $i)); $buf = substr($buf, $i + 1);
            if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
            elseif (str_starts_with($line, "data:")) {
                $data = json_decode(substr($line, 5), true);
                if ($event === "delta") echo $data["text"] ?? "";
                if ($event === "done") $done = $data;
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
$out = $done["output"];
$text = is_array($out) ? $out["output"] : $out; // authoritative
var req = new HttpRequestMessage(HttpMethod.Post, SkillSafe.Api + "/run-stream")
    { Content = JsonContent.Create(payload) };
req.Headers.Add("Idempotency-Key", $"wc-plan-{Guid.NewGuid()}");
var res = await SkillSafe.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? line; string ev = ""; JsonElement doneEl = default;
while ((line = await reader.ReadLineAsync()) != null)
{
    if (line.StartsWith("event:")) ev = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = JsonDocument.Parse(line[5..]).RootElement.Clone();
        if (ev == "delta") Console.Write(
            data.TryGetProperty("text", out var t) ? t.GetString() : "");
        else if (ev == "done") doneEl = data;
        else if (ev == "error") throw new Exception(line);
    }
}
var o = doneEl.GetProperty("output");
var text = o.ValueKind == JsonValueKind.String ? o.GetString()! : o.GetProperty("output").GetString()!;

Step 7 — Read the plan

The reply is plain text with no code fence and no preamble, always in exactly this frame — stable enough to parse with a few string splits:

LANE: plan
PLAN: <a title, at most 60 characters>
GOAL: <the goal in one line>
SUMMARY: <2-4 sentences: the shape of the plan and the one thing that matters most>

## Phases
- <name> | <YYYY-MM-DD start> | <YYYY-MM-DD end> | <focus in a few words>

## Sessions
- <YYYY-MM-DD> | <HH:MM> | <minutes> | <category> | <title> | <detail> | <RPE>

## Notes
- <a short coaching note>

## Adjustments
- None.
# plan.txt from step 5: print date, start, minutes, category, title of each session
sed -n '/^## Sessions/,/^## Notes/p' plan.txt | grep '^- [0-9]' \
  | sed 's/^- //' | awk -F ' [|] ' 'NF == 7 { print $1, $2, $3 "min", $4, $5 }'
CATEGORIES = {"easy", "long", "quality", "strength", "mobility", "cross", "race"}

def parse_plan(text):
    plan = {"phases": [], "sessions": [], "notes": [], "adjustments": []}
    section = None
    for line in text.splitlines():
        line = line.strip()
        if line.startswith("## "):
            section = line[3:].strip().lower()
        elif section is None and ":" in line:
            key, value = line.split(":", 1)       # LANE / PLAN / GOAL / SUMMARY
            plan[key.strip().lower()] = value.strip()
        elif line.startswith("- ") and line != "- None.":
            parts = [p.strip() for p in line[2:].split(" | ")]
            if section == "phases" and len(parts) == 4:
                plan["phases"].append(dict(zip(["name", "start", "end", "focus"], parts)))
            elif section == "sessions" and len(parts) == 7 and parts[3] in CATEGORIES:
                d, t, mins, cat, title, detail, rpe = parts
                plan["sessions"].append({"date": d, "start": t, "minutes": int(mins),
                                         "category": cat, "title": title,
                                         "detail": detail, "rpe": int(rpe)})
            elif section in ("notes", "adjustments"):
                plan[section].append(line[2:])
    return plan

plan = parse_plan(text)
assert plan["lane"] == payload["task"]
print(len(plan["sessions"]), "sessions")
const CATEGORIES = new Set(["easy", "long", "quality", "strength", "mobility", "cross", "race"]);

function parsePlan(text) {
  const plan = { phases: [], sessions: [], notes: [], adjustments: [] };
  let section = null;
  for (const raw of text.split("\n")) {
    const line = raw.trim();
    if (line.startsWith("## ")) section = line.slice(3).trim().toLowerCase();
    else if (section === null && line.includes(":")) {
      const i = line.indexOf(":");           // LANE / PLAN / GOAL / SUMMARY
      plan[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
    } else if (line.startsWith("- ") && line !== "- None.") {
      const p = line.slice(2).split(" | ").map((s) => s.trim());
      if (section === "phases" && p.length === 4)
        plan.phases.push({ name: p[0], start: p[1], end: p[2], focus: p[3] });
      else if (section === "sessions" && p.length === 7 && CATEGORIES.has(p[3]))
        plan.sessions.push({ date: p[0], start: p[1], minutes: Number(p[2]), category: p[3],
                             title: p[4], detail: p[5], rpe: Number(p[6]) });
      else if (section === "notes" || section === "adjustments") plan[section].push(line.slice(2));
    }
  }
  return plan;
}

const plan = parsePlan(text);
console.log(plan.lane, plan.sessions.length, "sessions");
type Session struct {
	Date, Start, Category, Title, Detail string
	Minutes, RPE                         int
}

func parseSessions(text string) []Session {
	var out []Session
	inSessions := false
	for _, line := range strings.Split(text, "\n") {
		line = strings.TrimSpace(line)
		if strings.HasPrefix(line, "## ") {
			inSessions = line == "## Sessions"
			continue
		}
		if !inSessions || !strings.HasPrefix(line, "- ") || line == "- None." {
			continue
		}
		p := strings.Split(line[2:], " | ")
		if len(p) != 7 {
			continue
		}
		mins, _ := strconv.Atoi(strings.TrimSpace(p[2]))
		rpe, _ := strconv.Atoi(strings.TrimSpace(p[6]))
		out = append(out, Session{Date: p[0], Start: p[1], Minutes: mins,
			Category: p[3], Title: p[4], Detail: p[5], RPE: rpe})
	}
	return out
}
record Session(String date, String start, int minutes, String category,
               String title, String detail, int rpe) {}

static List<Session> parseSessions(String text) {
    var out = new ArrayList<Session>();
    boolean inSessions = false;
    for (String raw : text.split("\n")) {
        String line = raw.strip();
        if (line.startsWith("## ")) { inSessions = line.equals("## Sessions"); continue; }
        if (!inSessions || !line.startsWith("- ") || line.equals("- None.")) continue;
        String[] p = line.substring(2).split(" \\| ");
        if (p.length != 7) continue;
        out.add(new Session(p[0].strip(), p[1].strip(), Integer.parseInt(p[2].strip()),
            p[3].strip(), p[4].strip(), p[5].strip(), Integer.parseInt(p[6].strip())));
    }
    return out;
}
def parse_sessions(text)
  section = nil
  text.each_line.filter_map do |raw|
    line = raw.strip
    if line.start_with?("## ")
      section = line[3..].strip
      next
    end
    next unless section == "Sessions" && line.start_with?("- ") && line != "- None."
    p = line[2..].split(" | ").map(&:strip)
    next unless p.size == 7
    { date: p[0], start: p[1], minutes: p[2].to_i, category: p[3],
      title: p[4], detail: p[5], rpe: p[6].to_i }
  end
end

sessions = parse_sessions(text)
puts "#{sessions.size} sessions, first: #{sessions.first&.dig(:title)}"
function parse_sessions(string $text): array {
    $out = []; $section = null;
    foreach (explode("\n", $text) as $raw) {
        $line = trim($raw);
        if (str_starts_with($line, "## ")) { $section = trim(substr($line, 3)); continue; }
        if ($section !== "Sessions" || !str_starts_with($line, "- ") || $line === "- None.") continue;
        $p = array_map("trim", explode(" | ", substr($line, 2)));
        if (count($p) !== 7) continue;
        $out[] = ["date" => $p[0], "start" => $p[1], "minutes" => (int)$p[2],
                  "category" => $p[3], "title" => $p[4], "detail" => $p[5], "rpe" => (int)$p[6]];
    }
    return $out;
}
record Session(string Date, string Start, int Minutes, string Category,
               string Title, string Detail, int Rpe);

static List<Session> ParseSessions(string text)
{
    var output = new List<Session>();
    var inSessions = false;
    foreach (var raw in text.Split('\n'))
    {
        var line = raw.Trim();
        if (line.StartsWith("## ")) { inSessions = line == "## Sessions"; continue; }
        if (!inSessions || !line.StartsWith("- ") || line == "- None.") continue;
        var p = line[2..].Split(" | ").Select(s => s.Trim()).ToArray();
        if (p.Length != 7) continue;
        output.Add(new Session(p[0], p[1], int.Parse(p[2]), p[3], p[4], p[5], int.Parse(p[6])));
    }
    return output;
}

Check every session against the calendar

The coach is instructed to keep every session inside one painted range for its weekday, off every commitment (weekly series included), between start_date and the end of the window with nothing after event_date, within sessions_per_week per Monday-to-Sunday week, within max_minutes (except the race), with no two sessions overlapping and no two of long/quality/race on consecutive days. That is a contract the prompt imposes on the model, not a server-side guarantee. The browser app checks every returned session against the painted hours and the commitments and outlines any that break a rule in red; an API caller should run the same checks on the parsed sessions before writing them into anyone's calendar — at minimum: start and start + minutes inside one availability range for that weekday, and no overlap with a commitments line on that date.

If a reply ever fails to match the frame (wrong LANE, a missing section, a session line without 7 fields), retry once with the same body plus a retry_note describing the problem and a new Idempotency-Key — the coach is instructed to obey it. That is exactly what the app itself does, and the retry is a second billed run.

Step 8 — Re-plan after a few weeks

POST /run

Keep the original body, switch task to "replan", set replan_from to the first date the new sessions may use, and write progress: a record line, then one line per recent session ending in done, missed or planned (see replan-input.json in step 3). The reply has the same frame; its ## Sessions start on or after replan_from and ## Adjustments explains what changed. Replace your stored sessions from replan_from onward and keep the earlier ones.

# replan-input.json from step 3 is the complete body
curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: wc-replan-$(date +%s)" \
  -d @replan-input.json | jq -r '.data.job_id'
# then poll /jobs/{job_id} as in step 5
# sessions: parsed earlier; marks: {date: "done" | "missed"} kept by your app
replan_from = "2026-10-26"
recent = [s for s in plan["sessions"] if s["date"] < replan_from]
lines = [f"- {s['date']} | {s['start']} | {s['minutes']} | {s['category']} | "
         f"{s['title']} | {marks.get(s['date'], 'planned')}" for s in recent]
done_n = sum(1 for s in recent if marks.get(s["date"]) == "done")
missed_n = sum(1 for s in recent if marks.get(s["date"]) == "missed")
record = f"record: {done_n} done, {missed_n} missed, {len(recent) - done_n - missed_n} unmarked before {replan_from}"

replan = dict(payload, task="replan", replan_from=replan_from,
              progress="\n".join([record] + lines))
job_id = api("POST", "/run", replan,
             **{"Idempotency-Key": f"wc-replan-{uuid.uuid4()}"})["job_id"]
const replanFrom = "2026-10-26";
const recent = plan.sessions.filter((s) => s.date < replanFrom);
const mark = (s) => marks[s.date] ?? "planned"; // marks: { date: "done" | "missed" }
const lines = recent.map((s) =>
  `- ${s.date} | ${s.start} | ${s.minutes} | ${s.category} | ${s.title} | ${mark(s)}`);
const doneN = recent.filter((s) => mark(s) === "done").length;
const missedN = recent.filter((s) => mark(s) === "missed").length;
const record = `record: ${doneN} done, ${missedN} missed, ${recent.length - doneN - missedN} unmarked before ${replanFrom}`;

const replan = { ...payload, task: "replan", replan_from: replanFrom,
                 progress: [record, ...lines].join("\n") };
const { job_id } = await api("POST", "/run", replan,
  { "Idempotency-Key": `wc-replan-${crypto.randomUUID()}` });
replanFrom := "2026-10-26"
lines := []string{} // record line first, filled below
doneN, missedN, total := 0, 0, 0
for _, s := range sessions {
	if s.Date >= replanFrom {
		continue
	}
	m := marks[s.Date] // "done", "missed" or "" (planned)
	if m == "" {
		m = "planned"
	}
	if m == "done" {
		doneN++
	} else if m == "missed" {
		missedN++
	}
	total++
	lines = append(lines, fmt.Sprintf("- %s | %s | %d | %s | %s | %s",
		s.Date, s.Start, s.Minutes, s.Category, s.Title, m))
}
record := fmt.Sprintf("record: %d done, %d missed, %d unmarked before %s",
	doneN, missedN, total-doneN-missedN, replanFrom)

payload["task"] = "replan"
payload["replan_from"] = replanFrom
payload["progress"] = strings.Join(append([]string{record}, lines...), "\n")
err := call("POST", "/run", payload, &started)
// simplest: send replan-input.json from step 3 as the body
String replanJson = java.nio.file.Files.readString(java.nio.file.Path.of("replan-input.json"));
String envelope = api("POST", "/run", replanJson);
// data.job_id - then poll /jobs/{job_id} as in step 5. To build it yourself,
// copy the plan body, set "task":"replan", "replan_from" and "progress"
// (record line + "- DATE | START | MIN | CATEGORY | TITLE | done|missed|planned").
replan_from = "2026-10-26"
recent = sessions.select { |s| s[:date] < replan_from }
mark = ->(s) { marks.fetch(s[:date], "planned") } # marks: { date => "done" | "missed" }
lines = recent.map { |s| "- #{s[:date]} | #{s[:start]} | #{s[:minutes]} | #{s[:category]} | #{s[:title]} | #{mark.(s)}" }
done_n = recent.count { |s| mark.(s) == "done" }
missed_n = recent.count { |s| mark.(s) == "missed" }
record = "record: #{done_n} done, #{missed_n} missed, #{recent.size - done_n - missed_n} unmarked before #{replan_from}"

replan = payload.merge("task" => "replan", "replan_from" => replan_from,
                       "progress" => ([record] + lines).join("\n"))
started = api("POST", "/run", replan, { "Idempotency-Key" => "wc-replan-#{SecureRandom.uuid}" })
$replanFrom = "2026-10-26";
$recent = array_filter($sessions, fn($s) => $s["date"] < $replanFrom);
$lines = []; $doneN = 0; $missedN = 0;
foreach ($recent as $s) {
    $m = $marks[$s["date"]] ?? "planned"; // "done" | "missed" | "planned"
    if ($m === "done") $doneN++;
    if ($m === "missed") $missedN++;
    $lines[] = "- {$s['date']} | {$s['start']} | {$s['minutes']} | {$s['category']} | {$s['title']} | $m";
}
$record = "record: $doneN done, $missedN missed, " . (count($recent) - $doneN - $missedN) . " unmarked before $replanFrom";

$replan = array_merge($payload, [
    "task" => "replan",
    "replan_from" => $replanFrom,
    "progress" => implode("\n", array_merge([$record], $lines)),
]);
$started = api("POST", "/run", $replan, ["Idempotency-Key: wc-replan-" . bin2hex(random_bytes(8))]);
var replanFrom = "2026-10-26";
var recent = sessions.Where(s => string.CompareOrdinal(s.Date, replanFrom) < 0).ToList();
string Mark(Session s) => marks.TryGetValue(s.Date, out var m) ? m : "planned";
var lines = recent.Select(s =>
    $"- {s.Date} | {s.Start} | {s.Minutes} | {s.Category} | {s.Title} | {Mark(s)}");
var doneN = recent.Count(s => Mark(s) == "done");
var missedN = recent.Count(s => Mark(s) == "missed");
var record = $"record: {doneN} done, {missedN} missed, {recent.Count - doneN - missedN} unmarked before {replanFrom}";

var replan = new Dictionary<string, object>(payload.ToDictionary(kv => kv.Key, kv => (object)kv.Value))
{
    ["task"] = "replan",
    ["replan_from"] = replanFrom,
    ["progress"] = string.Join("\n", new[] { record }.Concat(lines)),
};
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", replan,
    idempotencyKey: $"wc-replan-{Guid.NewGuid()}");