AI Trainer Chat
Overview
The AI Personal Trainer is a guided chat experience that walks the user through a personalized session: profile setup (with an optional camera-based fitness assessment) → readiness check-in → workout → post-workout check-in → scheduling the next session.
Your app only needs to mount the trainer view, optionally pre-fill user data, and listen for the events below — most importantly trainer_schedule_next_workout, which fires when the user picks a date/time for their next session and is the trigger for your reminder logic. Optionally, you can also gate workout generation behind your subscription flow and keep the fitness profile in sync with your app.
Prefer to build your own chat UI? The same intelligence is available over REST — see the AI Trainer API.
Requirements
Camera permission, an internet connection, and a recent SDK that supports the Trainer Chat integration:
| Platform | Minimum SDK |
| Swift (iOS) | KinesteXAIKit ≥ 1.1.3 — iOS 13+, Swift 5.5+, SwiftUI. ≥ 1.1.4 for subscription gating (adds the workoutAction binding) |
| Kotlin (Android) | KinesteX-SDK-Kotlin ≥ 2.0.6 |
| React Native | kinestex-sdk-react-native ≥ 1.2.9 |
| Flutter | kinestex_sdk_flutter ≥ 1.4.7 |
| React TS (Web) | kinestex-sdk-react-ts ≥ 0.0.3 |
| HTML & JS | iframe at https://ai.kinestex.com/trainer (HTTPS host page required) |
If you haven't installed the SDK yet, follow the installation guide first — only trainer-specific concerns are covered below.
Launching the Trainer
Mount the trainer with the AI_TRAINER_CHAT integration option (or, on Swift / Kotlin / Flutter, call createTrainerChatView). The trainer flow is rendered entirely inside the SDK.
1import SwiftUI
2import KinesteXAIKit
3
4struct TrainerScreen: View {
5 @State private var isLoading = false
6 @State private var showKinesteX = true
7
8 // KinesteXAIKit is configured per-instance — no global initialize call.
9 private let kit = KinesteXAIKit(
10 apiKey: "<YOUR_API_KEY>",
11 companyName: "<YOUR_COMPANY>",
12 userId: "<USER_ID>"
13 )
14
15 var body: some View {
16 if showKinesteX {
17 kit.createTrainerChatView(
18 user: nil, // or your UserDetails — see below
19 style: IStyle(style: "dark"),
20 isLoading: $isLoading,
21 onMessageReceived: handleMessage
22 )
23 }
24 }
25
26 private func handleMessage(_ message: KinestexMessage) { /* see below */ }
27}Pre-filling user data
The trainer asks profile questions on first use. You can skip the basic demographics by passing them when you mount the SDK — anything you omit, the trainer will simply ask for.
| Field | Unit / Allowed values |
age | years |
height | cm (UI lets users switch to imperial after) |
weight | kg |
gender | "male" or "female" |
Don't pass empty strings or zero — omit the field instead. On Swift, Kotlin, and Flutter the UserDetails initializer requires all fields, so either pass the complete value or skip user: entirely (nil on Swift, omit on Kotlin/Flutter).
Date of birth. Pass captureDateOfBirth: true in customParams to have the trainer ask for a full date of birth instead of a birth year, and date_of_birth ("YYYY-MM-DD") to prefill it. KinesteX stores only the age and keeps the date for the current launch only. The date comes back to you as date_of_birth in the profile events, so pass date_of_birth on every launch if you want to keep receiving it.
Fitness-profile prefill. Beyond demographics, you can pre-fill the trainer's fitness profile via customParams so returning users skip questions you already have answers for. The field names match the trainer_profile_updated event exactly:
| Field | Type | Notes | |||
fitness_goals | string[] | "weight_loss", "muscle_gain", "strength", "general_fitness", "wellness_flexibility", "cardio_endurance". The goal picker hides "weight_loss" for users with an underweight BMI (under 18.5), and a prefilled "weight_loss" is dropped when they answer the goals step | |||
injuries | object[] | `{ "body_part", "preference": "avoid" \ | "include", "severity": "light" \ | "moderate" \ | "severe" }` |
health_conditions | string[] | e.g. ["Asthma"] | |||
fitness_level_squats / _pushups / _cardio | string | Descriptive levels; the in-app assessment sets the first two automatically | |||
other_preferences | string | Free text, passed to the AI verbatim |
The trainer merges what you pass into the stored profile (your values win). Only send fields that hold real values — never placeholders, empty strings, or zeros.
1// UserDetails on Swift requires ALL five fields — pass the full value when
2// you know everything, otherwise pass `user: nil` and let the trainer ask.
3let user = UserDetails(
4 age: 32,
5 height: 178, // cm
6 weight: 76, // kg
7 gender: .Male,
8 lifestyle: .Active // not used in the trainer UI, but required by the initializer
9)
10
11kit.createTrainerChatView(
12 user: user,
13 style: IStyle(style: "dark"),
14 isLoading: $isLoading,
15 onMessageReceived: handleMessage
16)Branding & appearance
These optional customParams rebrand the trainer without any KinesteX involvement:
| Key | Type | Effect |
aiTrainerName | string | Renames the AI trainer everywhere it's labelled — the header title and the label above each reply. Shown verbatim in every language. Example: "My Coach" |
aiTrainerColor | string | Recolors the trainer's star icon — any CSS color, e.g. "#7C3AED" |
aiTrainerGreeting | string | Your own opening line, typed by the trainer at most once per launch (it is skipped when the launch resumes an onboarding step or a check-in). Shown as-is, so send it in the launch language. Example: "Welcome back, Priya! Ready to train?" |
aiTrainerOnboardingGreeting | string | Opening line when a user with no chat history starts onboarding. Falls back to aiTrainerGreeting |
aiTrainerOnboardingCompleteMessage | string | Line typed once when onboarding ends and the first plan starts generating |
Header buttons. The trainer header has a calendar button (opens My Progress) and a settings button (music, AI motion tracking and pose model, plus a "Profile and more settings" row). You can hand both over to your own screens or hide them:
| Key | Type | Effect |
customProfileAction | boolean | "Profile and more settings" sends trainer_profile_clicked with the current profile instead of opening the KinesteX profile screen |
customCalendarAction | boolean | The calendar button sends trainer_calendar_clicked instead of opening My Progress |
hideCalendarButton | boolean | Hides the calendar button |
hideProfileButton | boolean | Hides the settings button and everything in it. Takes priority over customProfileAction |
Both buttons are hidden while the user is in the onboarding wizard.
The overall theme (colors, fonts) is configured by KinesteX per company — send your KinesteX contact the palette you want, or manage it via your white-label theme.
Handling messages
The trainer emits the standard SDK events plus its own trainer-specific ones. All of them arrive as JSON with a snake_case type — on Swift/Kotlin/Flutter they arrive through the generic custom_type / CustomType case you already handle:
workout_exit_request— the user exited a workout that was launched from the trainer.trainer_schedule_next_workout— the user picked a date/time for their next session or assessment (see below).open_subscription_flow— a non-subscribed user tried to generate a workout (see Subscription gating).trainer_assessment_started/trainer_assessment_completed/trainer_assessment_skipped— in-app fitness assessment lifecycle (see Assessment & profile sync).trainer_profile_updated— the user's fitness profile changed; persist it on your side (see Assessment & profile sync).trainer_workout_days_updated: the user picked (or edited) the weekdays and time they want to train at (see Workout days & preferred time).trainer_profile_onboarding: fired at every applied step of the first-time onboarding wizard as{ "type", "stage", "data", "date" }, wheredataholds only that stage's answer. Fields that also exist in theprofileobject use the same names (e.g.stage: "fitness_goals",data: { "fitness_goals": ["weight_loss"] }). Stages:profile(includesdate_of_birthwhen known),fitness_goals,goal_recommendation,body_parts,injuries,injury_detail,training_style,fitness_level,equipment,duration,other_preferences,workout_frequency. It fires again when the user edits an answer card in the chat before the plan starts generating; later edits from settings arrive only astrainer_profile_updated. Capture the stages as they arrive to learn about users mid-onboarding; the confirmed questionnaire still ends with one fulltrainer_profile_updated. Used heavily by the Free Trial & Paid Conversion use case.trainer_profile_clicked: only withcustomProfileAction: true. The user tapped "Profile and more settings"; open your own profile screen. Carries{ "profile", "date" }, whereprofilehas the same shape as intrainer_profile_updated(nullwhen no profile is stored yet or it could not be loaded).trainer_calendar_clicked: only withcustomCalendarAction: true. The user tapped the calendar button; open your own progress screen. Carries{ "date" }.
Add the new cases to your existing handler.
1private func handleMessage(_ message: KinestexMessage) {
2 switch message {
3 case .exit_kinestex:
4 // User exited the trainer screen via the back button.
5 showKinesteX = false
6
7 case .error_occurred(let data):
8 print("KinesteX error:", data)
9
10 // `trainer_schedule_next_workout` is not yet a typed case — it
11 // arrives as .custom_type, so check the `type` field on the payload.
12 case .custom_type(let data):
13 guard let type = data["type"] as? String else { return }
14 switch type {
15 case "workout_exit_request":
16 // User exited a workout that was launched from the trainer.
17 break
18 case "trainer_schedule_next_workout":
19 // payload: { type, scheduledFor: "YYYY-MM-DDTHH:MM" }
20 if let scheduledFor = data["scheduledFor"] as? String {
21 scheduleNextWorkoutReminder(scheduledFor: scheduledFor)
22 }
23 default:
24 break
25 }
26
27 default:
28 break
29 }
30}Subscription gating
Workout generation can be gated behind your app's subscription; browsing, onboarding, the free fitness assessment, and Q&A stay available to everyone. KinesteX never shows a paywall and never processes payments — your app owns the purchase flow.
- Pass
isSubscribedincustomParamson every mount. Missing ortrue= subscribed; only an explicitfalsegates generation. - When a non-subscriber taps Generate workout, the SDK parks the request and emits
open_subscription_flow— overlay your subscription screen on top of the trainer view. - When your flow closes, post
{ "subscription_result": "purchased" }or{ "subscription_result": "dismissed" }back into the SDK (Swift: theworkoutActionbinding, ≥ 1.1.4; Kotlin:sendAction). On"purchased"the parked generation resumes automatically — no second tap. - Standalone/link integrations with no host app can pass
subscriptionReturnUrlinstead — the trainer redirects the browser there rather than posting the event.
Full walkthrough with per-platform code, edge cases, and a launch checklist: Subscription gating guide. To enforce the status server-side (so clients can't bypass it), see Session Auth & Managed Subscriptions. For the complete free-to-paid product flow this enables (free camera assessment, plan preview, locked chat input, conversion at peak intent), see the Free Trial & Paid Conversion use case.
Assessment & profile sync
In-app fitness assessment. During onboarding the trainer offers a guided, camera-based squat and push-up assessment (30 seconds each) instead of self-reporting fitness levels — results set the user's measured fitness level automatically. Users can also tap Set manually or schedule the assessment for later. The lifecycle is reported to your app (dates are DD MM YYYY HH:mm:ss in device-local time — informational, don't parse them for logic):
1{ "type": "trainer_assessment_started", "date": "09 07 2026 14:52:10" }
2{ "type": "trainer_assessment_skipped", "reason": "set_manually", "date": "09 07 2026 14:52:10" }
3{
4 "type": "trainer_assessment_completed",
5 "date": "09 07 2026 14:52:10",
6 "results": {
7 "squats": { "reps": 21, "level": "intermediate" },
8 "pushups": { "reps": 14, "level": "intermediate" }
9 },
10 "fitnessLevel": "intermediate"
11}level and fitnessLevel are "beginner", "intermediate", or "advanced". If a test was skipped (for example because of an injury), its entry is { "reps": null, "level": null }.
Profile sync (KinesteX → your app). Every profile change — onboarding answers, assessment results, edits the user makes in chat ("my knee hurts", "I'm 70kg now") — fires trainer_profile_updated with the full current profile:
1{
2 "type": "trainer_profile_updated",
3 "source": "assessment",
4 "profile": {
5 "age": 29,
6 "date_of_birth": "1997-03-21",
7 "height": 172,
8 "weight": 68,
9 "gender": "female",
10 "fitness_goals": ["weight_loss", "cardio_endurance"],
11 "fitness_level_squats": "I can do 21 squats in 30 seconds",
12 "fitness_level_pushups": "I can do 14 push ups in 30 seconds",
13 "fitness_level_cardio": "I can run 1.6km (1 mile) with walking breaks",
14 "injuries": [
15 { "body_part": "lower_back", "preference": "avoid", "severity": "moderate" }
16 ],
17 "health_conditions": [],
18 "other_preferences": "Prefers morning workouts",
19 "workout_days": ["mon", "wed", "fri"],
20 "workout_days_per_week": 3,
21 "preferred_workout_time": "18:00",
22 "updated_at": "2026-07-06T14:52:10Z"
23 }
24}source is "onboarding", "assessment", "chat", or "host" (an echo of your own update_trainer_profile, see below; skip it if you don't want to write back what you just sent). Profile values you pass at launch that differ from the saved profile are also saved, but echoed with source: "onboarding". Persist the whole profile object keyed by user and store updated_at (last write wins if both sides edit the same field). Empty arrays/strings are always included so you receive the complete current profile. A few fields are omitted until known: date_of_birth (only when a valid date was captured in this launch or passed in at launch, see Pre-filling user data) and the scheduling fields (see Workout days & preferred time).
Your app → KinesteX. At launch, pass current values: demographics via user: / UserDetails, everything else via customParams using the same field names as the event (see Pre-filling user data). To change the profile while the trainer is open, for example after the user edits it in your app, post an update_trainer_profile message the same way you post subscription_result:
1{ "type": "update_trainer_profile", "weight": 66, "fitness_goals": ["strength"] }Send only the fields that changed. It accepts age, date_of_birth, height (cm), weight (kg), gender, and the fitness-profile fields from the prefill table. age, height and weight must be numbers greater than 0, and unknown values are dropped. An empty other_preferences string clears it. Once the user has a saved profile (after onboarding or the in-app assessment), KinesteX saves any value that differs from it and sends trainer_profile_updated with source: "host". Before that, it only pre-answers the wizard. A date_of_birth change is saved only as the age, so it is echoed only when the age changes. The workout schedule can't be set this way yet.
Workout days & preferred time
During onboarding the trainer asks which weekdays the user wants to train on, and at what time. The answer reaches your app three different ways; pick whichever fits your integration.
1. A dedicated event, the moment they pick. trainer_workout_days_updated fires when the user taps Apply on the weekday step, and again on every later edit from the trainer's settings sheet:
1{
2 "type": "trainer_workout_days_updated",
3 "days": ["mon", "wed", "fri"],
4 "daysPerWeek": 3,
5 "preferredTime": "18:00"
6}Note the camelCase keys on this one. The two profile-shaped deliveries below use snake_case.
2. As an onboarding step. trainer_profile_onboarding with stage: "workout_frequency" carries the same answer in profile field names, alongside every other wizard step:
1{
2 "type": "trainer_profile_onboarding",
3 "stage": "workout_frequency",
4 "data": {
5 "workout_days": ["mon", "wed", "fri"],
6 "workout_days_per_week": 3,
7 "preferred_workout_time": "18:00"
8 },
9 "date": "..."
10}3. Inside the full profile. trainer_profile_updated carries the same three fields in its profile object on every profile change, so if you persist the profile (see Assessment & profile sync) you always hold the current schedule without handling either event above.
Formats. days / workout_days are always lowercase three-letter keys in week order ("mon", "tue", "wed", "thu", "fri", "sat", "sun"), regardless of the order the user tapped them or the language the trainer is displayed in. preferredTime / preferred_workout_time is the device-local time on a 24-hour clock ("HH:MM", no timezone). daysPerWeek / workout_days_per_week is simply the length of the list.
A missing schedule means "unknown", not "no days". The three profile fields are omitted entirely until the user has answered. One exception: an older profile can carry workout_days_per_week on its own, without workout_days or preferred_workout_time. Users who onboarded before this step existed have no schedule stored. The trainer asks them the question once, at their next post-workout check-in, and the events above fire then. Don't default a missing workout_days to an empty week.
This is what the reminder is computed from. `trainer_schedule_next_workout` takes the next selected weekday strictly after today, at the preferred time. Today never counts, so a single selected day always lands one week out. You don't need to derive it yourself, but if you schedule your own reminders straight off the raw days, apply the same rule so the two never disagree.
Currently one-way. The schedule is not a prefill field, and `update_trainer_profile` does not accept it, so you receive ours but can't push yours back yet. Ask your KinesteX contact if your app owns the user's training schedule natively.
Scheduling the next workout
When the user schedules their next workout — or the in-app fitness assessment — for later, the SDK fires:
1{
2 "type": "trainer_schedule_next_workout",
3 "scheduledFor": "2026-04-29T14:30",
4 "sessionType": "workout"
5}scheduledFor is `YYYY-MM-DDTHH:MM` with no timezone — interpret it as the device's local time (new Date(scheduledFor) in JS and DateTime.parse(scheduledFor) in Dart both do this correctly). The event is not sent if the user skips scheduling.
sessionType is "workout" or "assessment" — treat a missing value as "workout". Use it to word the reminder and to pick a stable notification identifier per session type so re-scheduling replaces instead of stacking.
After a completed workout, scheduledFor is derived from the weekdays the user picked during onboarding (the next selected day strictly after today, at their preferred time). You also receive those days directly, if you'd rather drive your own scheduling from them. See Workout days & preferred time.
The SDK does not schedule the reminder for you — it just tells you when. The host app delivers the reminder. Two options:
- Local notification (recommended for native). No backend, works offline, fires even if the app is force-quit. Example below.
- Server-side push. POST
{ userId, scheduledFor, timezone }to your backend and queue a push (APNs / FCM / web-push / OneSignal). Use this when the user has multiple devices, you want delivery analytics, or the platform has no reliable local-notification path (e.g. plain web).
Whichever you pick, use a stable identifier per user (e.g. "trainer-next-workout") so a re-schedule replaces the previous reminder instead of stacking, skip past timestamps, and request notification permission early.
1import UserNotifications
2
3private let trainerReminderId = "kinestex.trainer.next_workout" // stable per user
4
5func scheduleNextWorkoutReminder(scheduledFor: String) {
6 // Parse "YYYY-MM-DDTHH:MM" as local time. Don't use ISO8601DateFormatter —
7 // it expects seconds and/or a timezone designator.
8 let formatter = DateFormatter()
9 formatter.dateFormat = "yyyy-MM-dd'T'HH:mm"
10 formatter.timeZone = .current
11 formatter.locale = Locale(identifier: "en_US_POSIX")
12
13 guard let date = formatter.date(from: scheduledFor),
14 date.timeIntervalSinceNow > 5 else { return }
15
16 let center = UNUserNotificationCenter.current()
17 center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
18 guard granted else { return }
19
20 // Replace the previous reminder so re-schedules don't stack.
21 center.removePendingNotificationRequests(withIdentifiers: [trainerReminderId])
22
23 let content = UNMutableNotificationContent()
24 content.title = "Time for your workout"
25 content.body = "Your AI Trainer has your next session ready."
26 content.sound = .default
27
28 let components = Calendar.current.dateComponents(
29 [.year, .month, .day, .hour, .minute], from: date
30 )
31 let trigger = UNCalendarNotificationTrigger(
32 dateMatching: components, repeats: false
33 )
34 center.add(UNNotificationRequest(
35 identifier: trainerReminderId,
36 content: content,
37 trigger: trigger
38 ))
39 }
40}Quick checklist
KinesteXAIKit≥ 1.1.3 installed via Swift Package Manager.KinesteXAIKit(apiKey:companyName:userId:)instantiated where you mount the trainer.Mount via
kit.createTrainerChatView(...).Pass a fully-populated
UserDetails(age, height in cm, weight in kg, gender, lifestyle) when known; otherwiseuser: nil.Handle
trainer_schedule_next_workout(arrives as.custom_type) and schedule a reminder viaUNUserNotificationCenter(or push).Use a stable identifier (e.g.
"kinestex.trainer.next_workout") so re-schedules replace the previous reminder.NSCameraUsageDescriptionset inInfo.plistand notification permission requested early.