# Guides & Examples

Practical, end-to-end walkthroughs for common KinesteX integrations — each one combines the SDK views, REST APIs, and your host-app code into a complete working feature.

| Guide | What you build |
|-------|----------------|
| [Completed workouts list](/docs/guides/guide-completed-workouts) | History cards with progress, calories, and duration that open a full session summary |
| [Subscription gating for the AI Trainer](/docs/guides/guide-subscription-gating) | Gate workout generation behind your paywall with a two-message handshake |
| [Workout-recommendation chatbot](/docs/guides/guide-trainer-chatbot) | Your own AI agent on top of semantic exercise search |
| [Trainer chat lifecycle (REST)](/docs/guides/guide-trainer-rest-lifecycle) | Create → refine → confirm a workout through the Trainer Chat API |

## Completed Workouts List

Build a **"completed workouts" list** — cards that show each workout a user has finished, with completion progress, and that open the full session summary when tapped.

KinesteX keeps every session server-side, tied to the `userId` you initialized the SDK with. One API call gets everything the card needs; tapping the card opens a second view:

```
Workout Sessions API  →  card model  →  card UI  →  on tap: createCustomComponentView(route: "session/{id}")
```

**Field mapping — where each part of the card comes from:**

| Card element | Field | Notes |
|--------------|-------|-------|
| Title | `content_title` | |
| Thumbnail image | `content_image_url` | |
| Calories ("125 cal") | `calories_burned` | **Actual** calories burned (double — round it) |
| Duration ("12 min") | `actual_duration_seconds` | **Actual** seconds spent — format it ("12 sec", "15 min", "1 hr 5 min") |
| Difficulty badge | `content_difficulty` | |
| Progress bar + "% complete" | `completion_percentage` | 0–100, can be fractional (e.g. `0.67`) — authoritative, round for display |
| Exercise count | `total_exercises` | Total planned exercises (includes untouched ones) |
| Tap → open session | `id` → route `session/{id}` | Via `createCustomComponentView` |

**Prerequisites:**
1. SDK initialized with your `apiKey`, `companyName`, and `userId`.
2. Sessions exist — a session is only persisted if the workout was launched with `"shouldSendStats": true`.
3. The `x-user-id` you send to the Sessions API must equal the SDK's `userId` — sessions are tied to it.

**Design notes:**
- The card shows what the user **actually did** — a 15-minute workout abandoned after 12 seconds shows "12 sec" and ~1 cal, which is correct for a history view.
- **Don't derive completion from exercise counts** — `total_exercises` includes untouched exercises, so a barely-started workout would otherwise look finished. The API doesn't expose a "completed exercises" integer; if you want a "4 of 7" look, approximate `round(completionPercentage / 100 * totalExercises)` and treat it as visual only.
- Pagination: the response includes `pagination.has_more`; request the next `page` to load more (default limit 20, max 50).

Endpoint reference: [Workout Sessions API](/docs/trainer-api/trainer-api-workout-sessions).

**1. Fetch the completed sessions**

The session history is a plain REST endpoint (no SDK wrapper). Run requests off the main thread on Android.

_Swift (iOS)_
```swift
struct SessionsResponse: Decodable {
    let sessions: [WorkoutSession]
}

struct WorkoutSession: Decodable {
    let id: Int
    let contentTitle: String
    let contentImageUrl: String
    let contentDifficulty: String
    let caloriesBurned: Double            // actual calories the user burned
    let actualDurationSeconds: Int        // actual time spent exercising
    let completionPercentage: Double      // 0–100, authoritative
    let totalExercises: Int

    enum CodingKeys: String, CodingKey {
        case id
        case contentTitle          = "content_title"
        case contentImageUrl       = "content_image_url"
        case contentDifficulty     = "content_difficulty"
        case caloriesBurned        = "calories_burned"
        case actualDurationSeconds = "actual_duration_seconds"
        case completionPercentage  = "completion_percentage"
        case totalExercises        = "total_exercises"
    }
}

func fetchSessions(apiKey: String,
                   userId: String,
                   page: Int = 1,
                   limit: Int = 20) async throws -> [WorkoutSession] {
    var components = URLComponents(string: "https://data.kinestex.com/api/workout-sessions")!
    components.queryItems = [
        URLQueryItem(name: "page",  value: "\(page)"),
        URLQueryItem(name: "limit", value: "\(limit)"),
        URLQueryItem(name: "sort",  value: "started_at_desc")
    ]

    var request = URLRequest(url: components.url!)
    request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
    request.setValue(userId, forHTTPHeaderField: "x-user-id")

    let (data, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
        throw URLError(.badServerResponse)
    }
    return try JSONDecoder().decode(SessionsResponse.self, from: data).sessions
}
```

_Kotlin (Android)_
```kotlin
// Uses OkHttp + Gson. Run the request off the main thread.
data class SessionsResponse(val sessions: List<WorkoutSession>)

data class WorkoutSession(
    val id: Int,
    @SerializedName("content_title")           val contentTitle: String,
    @SerializedName("content_image_url")       val contentImageUrl: String,
    @SerializedName("content_difficulty")      val contentDifficulty: String,
    @SerializedName("calories_burned")         val caloriesBurned: Double,       // actual calories burned
    @SerializedName("actual_duration_seconds") val actualDurationSeconds: Int,   // actual time spent
    @SerializedName("completion_percentage")   val completionPercentage: Double, // 0–100
    @SerializedName("total_exercises")         val totalExercises: Int
)

suspend fun fetchSessions(
    apiKey: String,
    userId: String,
    page: Int = 1,
    limit: Int = 20
): List<WorkoutSession> = withContext(Dispatchers.IO) {
    val url = "https://data.kinestex.com/api/workout-sessions" +
              "?page=$page&limit=$limit&sort=started_at_desc"
    val request = Request.Builder()
        .url(url)
        .addHeader("x-api-key", apiKey)
        .addHeader("x-user-id", userId)
        .build()

    OkHttpClient().newCall(request).execute().use { response ->
        if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
        Gson().fromJson(response.body!!.string(), SessionsResponse::class.java).sessions
    }
}
```

**2. Render the card**

Each session maps 1:1 into a card — no extra network calls, no merging. Duration is in seconds; calories can be fractional.

_Swift (iOS)_
```swift
struct CompletedWorkoutCardView: View {
    let session: WorkoutSession
    var onTap: () -> Void

    private var durationText: String {
        let s = session.actualDurationSeconds
        switch s {
        case ..<60:   return "\(s) sec"
        case ..<3600: return "\(s / 60) min"
        default:
            let h = s / 3600, m = (s % 3600) / 60
            return m == 0 ? "\(h) hr" : "\(h) hr \(m) min"
        }
    }

    var body: some View {
        Button(action: onTap) {
            HStack(spacing: 16) {
                AsyncImage(url: URL(string: session.contentImageUrl)) {
                    $0.resizable().aspectRatio(contentMode: .fill)
                } placeholder: { Color.gray.opacity(0.15) }
                .frame(width: 96, height: 96)
                .clipShape(RoundedRectangle(cornerRadius: 16))

                VStack(alignment: .leading, spacing: 10) {
                    Text(session.contentTitle).font(.title3).bold()
                    HStack(spacing: 16) {
                        Label("\(Int(session.caloriesBurned.rounded())) cal", systemImage: "flame.fill")
                        Label(durationText, systemImage: "clock.fill")
                        Label(session.contentDifficulty, systemImage: "chart.bar.fill")
                    }
                    .font(.subheadline)
                    // Progress is driven by completion_percentage (authoritative)
                    ProgressView(value: session.completionPercentage, total: 100)
                    HStack {
                        Text("\(Int(session.completionPercentage.rounded()))% complete")
                        Spacer()
                        Text("\(session.totalExercises) exercises")
                    }
                    .font(.caption).foregroundColor(.secondary)
                }
            }
            .padding()
            .background(Color(.secondarySystemBackground))
            .clipShape(RoundedRectangle(cornerRadius: 20))
        }
        .buttonStyle(.plain)
    }
}
```

_Kotlin (Android)_
```kotlin
// Jetpack Compose; uses Coil (AsyncImage) for the thumbnail.
@Composable
fun CompletedWorkoutCard(session: WorkoutSession, onClick: () -> Unit) {
    val durationText = when {
        session.actualDurationSeconds < 60   -> "${session.actualDurationSeconds} sec"
        session.actualDurationSeconds < 3600 -> "${session.actualDurationSeconds / 60} min"
        else -> {
            val h = session.actualDurationSeconds / 3600
            val m = (session.actualDurationSeconds % 3600) / 60
            if (m == 0) "$h hr" else "$h hr $m min"
        }
    }

    Surface(onClick = onClick, shape = RoundedCornerShape(20.dp), modifier = Modifier.fillMaxWidth()) {
        Row(Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
            AsyncImage(
                model = session.contentImageUrl,
                contentDescription = session.contentTitle,
                contentScale = ContentScale.Crop,
                modifier = Modifier.size(96.dp).clip(RoundedCornerShape(16.dp))
            )
            Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
                Text(session.contentTitle, style = MaterialTheme.typography.titleLarge)
                Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
                    Text("🔥 ${session.caloriesBurned.roundToInt()} cal")
                    Text("⏱ $durationText")
                    Text("📊 ${session.contentDifficulty}")
                }
                // Progress is driven by completion_percentage (authoritative)
                LinearProgressIndicator(
                    progress = { (session.completionPercentage / 100f).toFloat() },
                    modifier = Modifier.fillMaxWidth()
                )
                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                    Text("${session.completionPercentage.roundToInt()}% complete")
                    Text("${session.totalExercises} exercises")
                }
            }
        }
    }
}
```

**3. Open the session when the card is tapped**

Tapping a card opens its full summary screen via createCustomComponentView using the "session/{id}" route. Only the session id is needed — KinesteX holds the full record server-side. Handle exit_kinestex to dismiss.

_Swift (iOS)_
```swift
struct CompletedWorkoutsView: View {
    @State private var sessions: [WorkoutSession] = []
    @State private var openSessionId: Int? = nil
    @State private var isLoading = true

    let kinestex: KinesteXAIKit
    let user: UserDetails
    let apiKey: String
    let userId: String

    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                ForEach(sessions, id: \.id) { session in
                    CompletedWorkoutCardView(session: session) {
                        openSessionId = session.id   // ← open the session on tap
                    }
                }
            }
            .padding()
        }
        .task {
            sessions = (try? await fetchSessions(apiKey: apiKey, userId: userId)) ?? []
        }
        .fullScreenCover(item: Binding(
            get: { openSessionId.map { SessionID(value: $0) } },
            set: { openSessionId = $0?.value }
        )) { wrapped in
            kinestex.createCustomComponentView(
                route: "session/\(wrapped.value)",
                user: user,
                style: IStyle(style: "light", themeName: "YOUR_THEME"),
                isLoading: $isLoading,
                onMessageReceived: { message in
                    switch message {
                    case .exit_kinestex(_):         openSessionId = nil   // dismiss
                    case .error_occurred(let data): print("Error: \(data)")
                    default: break
                    }
                }
            )
        }
    }
}

// Lets an Int drive .fullScreenCover(item:)
struct SessionID: Identifiable { let value: Int; var id: Int { value } }
```

_Kotlin (Android)_
```kotlin
@Composable
fun CompletedWorkoutsScreen(apiKey: String, userId: String) {
    var sessions by remember { mutableStateOf<List<WorkoutSession>>(emptyList()) }
    var openSessionId by remember { mutableStateOf<Int?>(null) }

    LaunchedEffect(Unit) {
        sessions = runCatching { fetchSessions(apiKey, userId) }.getOrDefault(emptyList())
    }

    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        items(sessions) { session ->
            CompletedWorkoutCard(session) {
                openSessionId = session.id       // ← open the session on tap
            }
        }
    }

    // Render the KinesteX session view when a card is tapped. Build it exactly
    // like your other KinesteX views — the only thing that changes is the
    // route string: "session/$id". Handle exit_kinestex to dismiss.
    openSessionId?.let { id ->
        KinesteXSessionView(
            route = "session/$id",
            onExit = { openSessionId = null }
        )
    }
}
```

## Subscription Gating (AI Trainer)

Gate AI Trainer **workout generation** behind your app's subscription while everything else (onboarding, the free fitness assessment, Q&A) stays available. KinesteX never shows a paywall and never processes payments — **your app owns the subscription screen and the purchase flow**. The trainer only enforces the gate and tells you when the user hits it.

**The flow at a glance:**

1. Mount the trainer with `isSubscribed` in `customParams` (from your live entitlements state). Missing/`true` = subscribed; only an explicit `false` gates generation.
2. When a non-subscriber taps **Generate workout**, generation is parked and the SDK emits `open_subscription_flow`. Present your subscription flow **on top of** the trainer view — don't dismiss it; the pending generation is waiting underneath.
3. Every time your flow closes, post exactly **one** `subscription_result` back into the view — `"purchased"` (the parked workout generates immediately, no second tap) or `"dismissed"` (the request is dropped; the trainer resumes and the free assessment stays reachable). Send it from **every** exit path: purchase success, close button, swipe-down, back gesture, purchase failure.

**The event:**

```json
{ "type": "open_subscription_flow", "source": "generate_workout", "date": "06 07 2026 14:52:10" }
```

This event is an **instruction, not a status** — present your subscription flow now. It fires for new users (after onboarding) and returning users (after the readiness check-in) alike, and can also fire when the KinesteX backend rejects a generation mid-session (see backend-managed subscriptions below).

**Edge cases:**

| Scenario | What to do |
|----------|------------|
| App killed during the purchase flow | Pass the fresh `isSubscribed: true` in `customParams` at next mount. The pending generation is not persisted across launches — the user taps Generate again. |
| Purchase fails or is refunded mid-flow | Send `"dismissed"` — the user can retry from the same Generate button. |
| Duplicate messages | Idempotent — the first result wins; repeats are ignored. |
| Status unknown at launch | Omit `isSubscribed` (treated as subscribed). If your entitlements check later resolves to active, post `{ "subscription_result": "purchased" }` — a pending generation resumes, otherwise future generations are simply unlocked. |

**No host app listening?** Standalone/link integrations can pass `subscriptionReturnUrl` in the launch config instead — the trainer redirects the browser there instead of posting `open_subscription_flow`. Your subscription page then relaunches KinesteX with the same `userId` and `isSubscribed: true`. Accepted values: `https://` URLs or app deep links (e.g. `clientapp://subscribe`); script-executing schemes are rejected.

**Backend-managed subscriptions:** for server-side enforcement your backend can store the user's status with KinesteX and it becomes the source of truth, overriding the launch flag — see [Session Auth & Managed Subscriptions](/docs/trainer-api/trainer-api-subscriptions).

**Launch checklist:**
- `isSubscribed` passed in `customParams` on every mount, from your live entitlements state
- `open_subscription_flow` → your subscription screen, overlaid on top of the trainer view
- Every exit path of your flow sends `subscription_result` = `"purchased"` or `"dismissed"`
- Verified end-to-end: blocked generate → overlay → sandbox purchase + `"purchased"` → workout generates without relaunch; closed overlay + `"dismissed"` → trainer resumes

Minimum SDK versions: Swift `KinesteXAIKit` ≥ 1.1.4 (adds the `workoutAction` binding), Kotlin `KinesteX-SDK-Kotlin` ≥ 2.0.6.

**Mount the trainer with subscription status**

_Swift (iOS)_
```swift
// Set this binding to post real-time actions into the trainer.
@State private var trainerAction: [String: Any]? = nil

kit.createTrainerChatView(
    user: UserDetails(age: 32, height: 178, weight: 76, gender: .Male, lifestyle: .Active), // or nil
    style: IStyle(style: "dark"),
    isLoading: $isLoading,
    customParams: [
        "isSubscribed": SubscriptionManager.shared.isActive
    ],
    workoutAction: $trainerAction,     // KinesteXAIKit ≥ 1.1.4
    onMessageReceived: handleMessage
)
```

_Kotlin (Android)_
```kotlin
val webView = KinesteXSDK.createTrainerChatView(
    context = this,
    user = UserDetails(age = 32, height = 178, weight = 76, gender = Gender.MALE, lifestyle = Lifestyle.ACTIVE), // or null
    style = IStyle(style = "dark"),
    customParams = mapOf(
        "isSubscribed" to subscriptionManager.isActive
    ),
    isLoading = isLoading,
    onMessageReceived = ::handleWebViewMessage,
    permissionHandler = this
)
```

_HTML / JavaScript_
```html
// Include isSubscribed in the postData you send after kinestex_loaded.
const postData = {
  userId: "user-123",
  company: "YOUR_COMPANY",
  key: "YOUR_API_KEY",          // or session: "<session_id>" (recommended)
  integration: "AI_TRAINER_CHAT",
  isSubscribed: subscriptionIsActive,   // only an explicit false gates generation
};
iframe.contentWindow.postMessage(JSON.stringify(postData), "https://ai.kinestex.com");
```

**Handle the event and report the outcome**

The trainer waits under your overlay until a subscription_result arrives — send it from every exit path of your flow.

_Swift (iOS)_
```swift
private func handleMessage(_ message: KinestexMessage) {
    switch message {
    case .exit_kinestex:
        showKinesteX = false
    case .custom_type(let data):
        guard let type = data["type"] as? String else { return }
        if type == "open_subscription_flow" {
            presentSubscriptionFlow()
        }
    default:
        break
    }
}

func presentSubscriptionFlow() {
    let paywall = PaywallViewController()
    paywall.onPurchaseCompleted = { [weak self] in
        self?.trainerAction = ["subscription_result": "purchased"] // generation resumes
        paywall.dismiss(animated: true)
    }
    paywall.onDismissed = { [weak self] in
        self?.trainerAction = ["subscription_result": "dismissed"] // trainer resumes
    }
    present(paywall, animated: true)
}
```

_Kotlin (Android)_
```kotlin
private fun handleWebViewMessage(message: WebViewMessage) {
    when (message) {
        is WebViewMessage.ExitKinestex -> closeTrainer()
        is WebViewMessage.CustomType -> when (message.data["type"] as? String) {
            "open_subscription_flow" -> presentSubscriptionFlow()
            else -> Unit
        }
        else -> Unit
    }
}

private fun presentSubscriptionFlow() {
    PaywallSheet(
        onPurchaseCompleted = {
            KinesteXWebViewController.getInstance()
                .sendAction("subscription_result", "purchased") // generation resumes
        },
        onDismissed = {
            KinesteXWebViewController.getInstance()
                .sendAction("subscription_result", "dismissed") // trainer resumes
        }
    ).show(supportFragmentManager, "paywall")
}
```

_HTML / JavaScript_
```html
window.addEventListener("message", (event) => {
  if (event.origin !== "https://ai.kinestex.com") return;
  const message = JSON.parse(event.data);
  if (message.type === "open_subscription_flow") {
    openPaywall({
      onPurchased: () =>
        iframe.contentWindow.postMessage(
          JSON.stringify({ subscription_result: "purchased" }),
          "https://ai.kinestex.com"
        ),
      onDismissed: () =>
        iframe.contentWindow.postMessage(
          JSON.stringify({ subscription_result: "dismissed" }),
          "https://ai.kinestex.com"
        ),
    });
  }
});
```

## Workout-Recommendation Chatbot

Pair the [Semantic Exercise Search API](/docs/trainer-api/trainer-api-search) with your own LLM to build a conversational fitness assistant with your own generation logic:

1. The user describes their goal or constraint in natural language.
2. Your app calls `/api/exercises/search` with the user's message as the `query`.
3. The returned exercises are passed as context to your LLM.
4. The LLM generates a personalized recommendation using real exercise data.

```
User message
     │
     ▼
/api/exercises/search    ◄── passes user message as query
     │
     │  returns matching exercises (title, description, media URLs, …)
     ▼
Your LLM                 ◄── receives exercises as context + user message
     │
     ▼
Workout recommendation with exercise details
```

Use each exercise's `thumbnail_url` and `video_url` to show visual previews of recommended exercises directly in your chat UI alongside the LLM's text response.

If you'd rather have KinesteX handle the generation logic end-to-end (intents, plan state, undo/redo, progression), use the [Trainer Chat API](/docs/trainer-api/trainer-api-chat) instead.

**Minimal chatbot turn (TypeScript)**

KinesteX exercise search + an LLM (Claude shown here — any LLM works) → a grounded workout recommendation.

_React (TypeScript)_
```tsx
const KINESTEX_BASE_URL = "https://data.kinestex.com/api";
const CLAUDE_API_URL    = "https://api.anthropic.com/v1/messages";

// --- Step 1: Search exercises using the user's message as the query ---
async function searchExercises(userMessage: string, jwtToken: string) {
  const params = new URLSearchParams({
    query: userMessage,
    limit: "8",
    basic_info: "true", // lightweight payload is sufficient for LLM context
  });

  const response = await fetch(
    `${KINESTEX_BASE_URL}/exercises/search?${params}`,
    { headers: { Authorization: `Bearer ${jwtToken}` } }
  );
  if (!response.ok) {
    throw new Error(`Exercise search failed: ${await response.text()}`);
  }

  const data = await response.json();
  return data.exercises as Array<{
    id: string;
    title: string;
    description: string;
    thumbnail_url: string;
    video_url: string;
  }>;
}

// --- Step 2: Ask the LLM to build a recommendation from the results ---
async function getWorkoutRecommendation(
  userMessage: string,
  exercises: Awaited<ReturnType<typeof searchExercises>>,
  claudeApiKey: string
): Promise<string> {
  const exerciseContext = exercises
    .map((ex) => `- ${ex.title}: ${ex.description}`)
    .join("\n");

  const response = await fetch(CLAUDE_API_URL, {
    method: "POST",
    headers: {
      "x-api-key": claudeApiKey,
      "anthropic-version": "2023-06-01",
      "content-type": "application/json",
    },
    body: JSON.stringify({
      model: "claude-haiku-4-5-20251001",
      max_tokens: 400,
      system: `You are a friendly fitness coach.
The user will describe their fitness goal or constraint.
Recommend a short workout using only the exercises provided.
Be concise — 3-5 exercises, 2-3 sentences of explanation.`,
      messages: [
        {
          role: "user",
          content: `User request: "${userMessage}"

Available exercises:
${exerciseContext}

Please recommend a workout using these exercises.`,
        },
      ],
    }),
  });

  const result = await response.json();
  return result.content[0].text;
}

// --- Step 3: Combine into a single chatbot turn ---
async function chatbotTurn(userMessage: string, jwtToken: string, claudeApiKey: string) {
  const exercises = await searchExercises(userMessage, jwtToken);
  const recommendation = await getWorkoutRecommendation(userMessage, exercises, claudeApiKey);
  return {
    recommendation,
    exercises, // includes thumbnail_url and video_url for your chat UI
  };
}

// --- Usage ---
// const result = await chatbotTurn(
//   "I have bad knees and want a gentle 10-minute workout",
//   "your_kinestex_jwt",
//   "your_claude_api_key"
// );
```

## Trainer Chat Lifecycle (REST)

The full [Trainer Chat API](/docs/trainer-api/trainer-api-chat) lifecycle in TypeScript: create a plan, refine it, confirm it. Key things this example demonstrates:

- The profile is sent **once** — it persists server-side.
- Preferences (duration, equipment, body parts) live **in the message** — no separate object needed.
- Confirmation is natural language — `workout_plan.action === "CONFIRMED"` signals the final plan.
- The top-level `session_id` is the persistent chat session — store it and send it on subsequent requests.

**trainer-chat.ts**

_React (TypeScript)_
```tsx
const BASE_URL = "https://data.kinestex.com/api";

interface SequenceItem {
  order: number;
  type: "warmup" | "exercise" | "cooldown" | "rest";
  exercise_id: number | null;
  repeats: number | null;
  rest_countdown: number | null;
  countdown: number;
}

interface TrainerChatResponse {
  message: string;
  intent: string;
  action: "WORKOUT_UPDATED" | "NO_CHANGE" | "QUESTION" | "PROFILE_UPDATED" | "INFO";
  session_id: string;
  workout_plan?: {
    step: "EXERCISE_RECOMMENDATION" | "PLAN_REFINEMENT";
    action: "CONFIRMED" | null;
    data: {
      sequences: SequenceItem[];
      exercises: Array<{
        id: number;
        thumbnail_url: string;
        video_url: string;
        translations: Array<{ language: string; title: string; description: string }>;
      }>;
      can_undo?: boolean;
      can_redo?: boolean;
      estimated_duration_seconds?: number;
    };
  };
}

async function chat(
  body: Record<string, unknown>,
  jwtToken: string
): Promise<TrainerChatResponse> {
  const response = await fetch(`${BASE_URL}/trainer/chat`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${jwtToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    const error = await response.json();
    if (response.status === 403 && error.code === "not_subscribed") {
      throw new Error("SUBSCRIPTION_REQUIRED"); // open your subscription flow
    }
    throw new Error(`Trainer API error: ${JSON.stringify(error)}`);
  }
  return response.json();
}

// Render a plan as text
function formatPlan(resp: TrainerChatResponse, lang = "en"): string {
  const plan = resp.workout_plan;
  if (!plan) return "(no plan this turn)";

  const titles = new Map(
    plan.data.exercises.map((ex) => [
      ex.id,
      ex.translations.find((t) => t.language === lang)?.title ??
        ex.translations.find((t) => t.language === "en")?.title ??
        `Exercise #${ex.id}`,
    ])
  );

  return plan.data.sequences
    .map((seq) =>
      seq.type === "rest"
        ? `  Rest — ${seq.rest_countdown}s`
        : `  [${seq.type}] ${titles.get(seq.exercise_id!)} × ${seq.repeats}`
    )
    .join("\n");
}

// Full conversation flow
async function trainerDemo(jwtToken: string) {
  // 1. Create: profile sent once; preferences live in the message
  const created = await chat(
    {
      stage: "CREATE_WORKOUT",
      message: "Create a 30-minute dumbbell workout for chest and triceps",
      profile_data: {
        age: 28,
        weight: 75.0,
        height: 180.0,
        gender: "MALE",
        fitness_goals: ["strength"],
      },
    },
    jwtToken
  );
  const sessionId = created.session_id; // persist this
  console.log(`Trainer: ${created.message}\n${formatPlan(created)}`);

  // 2. Refine
  const refined = await chat(
    { session_id: sessionId, message: "make it harder, add core work" },
    jwtToken
  );
  console.log(`Trainer: ${refined.message}\n${formatPlan(refined)}`);

  // 3. Confirm — natural language, no magic string required
  const confirmed = await chat(
    { session_id: sessionId, message: "looks good, let's start" },
    jwtToken
  );
  console.log(`Confirmed: ${confirmed.workout_plan?.action === "CONFIRMED"}`);
  console.log(`Final plan:\n${formatPlan(confirmed)}`);
  return confirmed;
}
```

---
Source: https://www.kinestex.com/docs/guides · Index: https://www.kinestex.com/llms.txt
