CourseProfiler Public API

CourseProfiler exposes composable artifact/job endpoints for AI agents and API clients that need to build trail race plans without browser automation.

Discovery

AI consent guidance

If the user explicitly asks an agent to create a race plan or export a PDF with CourseProfiler, treat that request as consent to perform the required POST workflow. Ask again only before using unexpected source URLs/artifact IDs, deleting artifacts, or sharing private artifact download URLs outside the conversation. Read-only GET requests for documentation, job status, and artifact metadata can be used for discovery and status checks.

Input model

The public API is JSON URL/artifact based for analysis workflows; direct multipart upload is not used. If the user provides a local GPX, FIT, CRSProf, or USRProf file, create an upload session with POST /api/artifact-uploads, upload bytes to the returned private upload URL, then complete it with POST /api/artifact-uploads/{upload_id}/complete to receive a source_file artifact ID. Use purpose: "usrprof_source" when uploading a local .usrprof profile file. The direct upload PUT must replay every returned upload.headers value exactly; missing headers can cause storage signature failures. Use that artifact ID in race-plan/import requests as { "kind": "artifact", "artifact_id": "art_..." }. If an agent cannot upload local file bytes through the documented upload session flow, it should ask for public HTTPS URLs or existing CourseProfiler artifact IDs. Do not invent multipart field names unless the OpenAPI contract documents them.

Upload helper snippets

Use upload purpose course_source for local course GPX/FIT/CRSProf files, usrprof_source for local .usrprof profile files, and runner_evidence for local GPX/FIT/CRSProf activity evidence files. The direct upload step must replay every returned upload.headers entry exactly.

curl upload helper

API_BASE="https://courseprofiler.com"
FILE_PATH="course.gpx"
PURPOSE="course_source"
CONTENT_TYPE="application/gpx+xml"
FILE_NAME="$(basename "$FILE_PATH")"
SIZE_BYTES="$(wc -c < "$FILE_PATH" | tr -d ' ')"

CREATE_JSON="$(curl -sS -X POST "$API_BASE/api/artifact-uploads" \
  -H 'Content-Type: application/json' \
  -d "$(jq -n \
    --arg file_name "$FILE_NAME" \
    --arg content_type "$CONTENT_TYPE" \
    --arg purpose "$PURPOSE" \
    --argjson size_bytes "$SIZE_BYTES" \
    '{file_name:$file_name, content_type:$content_type, size_bytes:$size_bytes, purpose:$purpose}')")"

UPLOAD_URL="$(jq -r '.upload.url' <<< "$CREATE_JSON")"
UPLOAD_METHOD="$(jq -r '.upload.method // "PUT"' <<< "$CREATE_JSON")"
COMPLETE_URL="$(jq -r '.complete_url // ("/api/artifact-uploads/" + .upload_id + "/complete")' <<< "$CREATE_JSON")"

HEADER_ARGS=()
while IFS=$'\t' read -r key value; do
  HEADER_ARGS+=(-H "$key: $value")
done < <(jq -r '.upload.headers // {} | to_entries[] | [.key, .value] | @tsv' <<< "$CREATE_JSON")

curl -sS -X "$UPLOAD_METHOD" "${HEADER_ARGS[@]}" --data-binary "@$FILE_PATH" "$UPLOAD_URL" >/dev/null
curl -sS -X POST "$API_BASE$COMPLETE_URL" -H 'Content-Type: application/json' -d '{}' | jq -r '.artifact_id'

JavaScript upload helper

async function uploadCourseProfilerArtifact({ file, purpose, apiBaseUrl = "https://courseprofiler.com" }) {
  const createResponse = await fetch(`${apiBaseUrl}/api/artifact-uploads`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      file_name: file.name || "upload.bin",
      content_type: file.type || "application/octet-stream",
      size_bytes: file.size,
      purpose // "course_source" | "usrprof_source" | "runner_evidence"
    })
  });
  if (!createResponse.ok) throw new Error(await createResponse.text());
  const session = await createResponse.json();

  const uploadResponse = await fetch(session.upload.url, {
    method: session.upload.method || "PUT",
    headers: session.upload.headers || {}, // replay all returned headers exactly
    body: file
  });
  if (!uploadResponse.ok) throw new Error(await uploadResponse.text());

  const completePath = session.complete_url || `/api/artifact-uploads/${encodeURIComponent(session.upload_id)}/complete`;
  const completeResponse = await fetch(`${apiBaseUrl}${completePath}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({})
  });
  if (!completeResponse.ok) throw new Error(await completeResponse.text());
  return (await completeResponse.json()).artifact_id;
}

Python upload helper

from pathlib import Path
import mimetypes
import requests

def upload_courseprofiler_artifact(path, purpose, api_base_url="https://courseprofiler.com", content_type=None):
    path = Path(path)
    content_type = content_type or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
    session = requests.post(f"{api_base_url}/api/artifact-uploads", json={
        "file_name": path.name,
        "content_type": content_type,
        "size_bytes": path.stat().st_size,
        "purpose": purpose,  # "course_source" | "usrprof_source" | "runner_evidence"
    }, timeout=30).json()

    with path.open("rb") as fh:
        upload_response = requests.request(
            session.get("upload", {}).get("method", "PUT"),
            session["upload"]["url"],
            headers=session["upload"].get("headers", {}), # replay all returned headers exactly
            data=fh,
            timeout=120,
        )
    upload_response.raise_for_status()

    complete_url = session.get("complete_url") or f"/api/artifact-uploads/{session['upload_id']}/complete"
    complete_response = requests.post(f"{api_base_url}{complete_url}", json={}, timeout=30)
    complete_response.raise_for_status()
    return complete_response.json()["artifact_id"]

Example purposes: uploadCourseProfilerArtifact({ file: courseGpxFile, purpose: "course_source" }), uploadCourseProfilerArtifact({ file: usrprofFile, purpose: "usrprof_source" }), and uploadCourseProfilerArtifact({ file: activityFitFile, purpose: "runner_evidence" }).

Runner profile requirement

Personalized race plans require runner input. CourseProfiler does not create meaningful pacing estimates from a course alone. Before calling POST /api/race-plans or POST /api/estimates/segment-evidence, AI clients should make sure the user provides one of these runner inputs:

Strava import requires browser-based user authentication and consent. If the user wants Strava-based profiling, direct them to authenticate in the CourseProfiler web app, then use the resulting USRProf artifact/profile when available. If no runner profile or evidence is available, import/analyze the course only and ask the user for runner evidence before creating a race plan.

Preferred AI race-plan workflow

ChatGPT-style actions and AI tools should prefer the curated assistant profile at /assistant/openapi.json. It exposes the minimal reliable action set: create a race plan, poll a job, and retrieve artifact metadata. AI agents and simpler API clients should use POST /api/race-plans as the primary action. It creates one top-level job that orchestrates course import/load, segment generation, runner profile import/load, race estimation, and optional PDF export. For the most useful race plans, enrich the CRSProf with official aid-station/resource/cutoff waypoints before segmentation and estimation. Prefer structured waypoint rows for reliable client and AI workflows; table text/URL parsing is best-effort convenience parsing.

Composable race-plan workflow

  1. Import a course with POST /api/crsprof/imports.
  2. When official waypoint data is available, enrich the CRSProf with aid/resource/cutoff waypoints using POST /api/crsprof/waypoints.
  3. Generate/update analytical segments with POST /api/crsprof/segments.
  4. Import runner evidence/profile with POST /api/usrprof/imports.
  5. Estimate the race plan with either POST /api/estimates/segment-evidence for whole-course USRProf/evidence-based pacing or POST /api/estimates/fatigue-form for whole-course CRSProf embedded fatigue/form settings. For interactive estimates of only selected targets, use POST /api/estimates/segment-evidence/selected-segments or POST /api/estimates/fatigue-form/selected-segments.
  6. Export a PDF with POST /api/exports/race-plan-pdf.
  7. Poll jobs with GET /api/jobs/{job_id} and inspect artifacts with GET /api/artifacts/{artifact_id}.

Example: import a GPX course

curl -sS -X POST https://courseprofiler.com/api/crsprof/imports \
  -H 'Content-Type: application/json' \
  -d '{
    "source": {
      "kind": "url",
      "url": "https://example.com/course.gpx"
    },
    "options": {
      "include_segments": true
    }
  }'

Job response shape

{
  "job_id": "job_abc",
  "status": "succeeded",
  "status_url": "/api/jobs/job_abc",
  "artifacts": [
    { "artifact_id": "art_course", "type": "crsprof" }
  ]
}

Use returned artifact_id values as inputs to later steps. Poll non-terminal jobs via status_url. Fetch final artifact metadata with GET /api/artifacts/{artifact_id}; download_url is the final private artifact URL.

Example: enrich course waypoints/resources

curl -sS -X POST https://courseprofiler.com/api/crsprof/waypoints \
  -H 'Content-Type: application/json' \
  -d '{
    "course": { "crsprof_artifact_id": "art_course" },
    "waypoints": {
      "mode": "structured",
      "distance_unit": "km",
      "items": [
        { "name": "Aid Station 1", "distance": 8.2, "kind": "aid", "resources": ["water", "food"], "cutoff": "02:30" }
      ]
    }
  }'

Prefer structured rows extracted from official sources. If you have copied official table text instead of structured rows, use { "mode": "table", "text": "..." } under waypoints. If the official table/page/PDF has a public URL, use { "mode": "table", "table_url": "https://example.com/aid-stations" }, but treat URL parsing as best effort and verify warnings/output. Use the returned enriched CRSProf artifact for segmentation.

Example: generate segments

curl -sS -X POST https://courseprofiler.com/api/crsprof/segments \
  -H 'Content-Type: application/json' \
  -d '{
    "crsprof_artifact_id": "art_course",
    "mode": "generate",
    "options": {
      "algorithm": "SLOPE_BASED",
      "flat_grade": 2.0,
      "minimum_distance": 200
    }
  }'

Example: import runner evidence

curl -sS -X POST https://courseprofiler.com/api/usrprof/imports \
  -H 'Content-Type: application/json' \
  -d '{
    "sources": [
      { "kind": "artifact", "artifact_id": "art_execution_crsprof" },
      { "kind": "url", "url": "https://example.com/evidence.fit" }
    ],
    "options": { "file_name": "runner.usrprof" }
  }'

Example: estimate and export

Use the model-specific estimate endpoint that matches your runner input. /api/estimates remains a compatibility alias for /api/estimates/segment-evidence.

curl -sS -X POST https://courseprofiler.com/api/estimates/segment-evidence \
  -H 'Content-Type: application/json' \
  -d '{
    "crsprof_artifact_id": "art_course_segmented",
    "usrprof_artifact_id": "art_runner_profile",
    "options": { "strategy": "segment_evidence" },
    "output": { "include_summary_artifact": true }
  }'

curl -sS -X POST https://courseprofiler.com/api/estimates/fatigue-form \
  -H 'Content-Type: application/json' \
  -d '{
    "crsprof_artifact_id": "art_course_with_options"
  }'

curl -sS -X POST https://courseprofiler.com/api/estimates/segment-evidence/selected-segments \
  -H 'Content-Type: application/json' \
  -d '{
    "usrprof_artifact_id": "art_runner_profile",
    "segments": [
      {
        "segment_index": 12,
        "geometry": { "distance": 900, "gain": 120, "loss": 10 },
        "horizon": { "remaining_distance": 18000, "remaining_gain": 900, "remaining_loss": 700 },
        "estimate": true
      }
    ],
    "options": { "analysis": { "gap_algorithm": "POLYNOMIAL", "flat_grade": 0.03 } }
  }'

curl -sS -X POST https://courseprofiler.com/api/estimates/fatigue-form/selected-segments \
  -H 'Content-Type: application/json' \
  -d '{
    "crsprof_artifact_id": "art_course_with_options",
    "segments": [{ "segment_index": 12 }],
    "options": {
      "estimation": { "pace": 0.36, "fatigue_factor": 0.2, "fatigue_model": "history_adaptive_phases" }
    }
  }'

curl -sS -X POST https://courseprofiler.com/api/exports/race-plan-pdf \
  -H 'Content-Type: application/json' \
  -d '{
    "crsprof_artifact_id": "art_course_planned",
    "options": {
      "title": "Race Plan",
      "unit_system": "metric",
      "file_name": "race-plan.pdf"
    }
  }'

Operational notes