diff --git a/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/1-introduce-plank-tutor.md b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/1-introduce-plank-tutor.md
new file mode 100644
index 0000000000..440c3a6cbd
--- /dev/null
+++ b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/1-introduce-plank-tutor.md
@@ -0,0 +1,99 @@
+---
+title: AI Plank Tutor
+weight: 2
+
+### FIXED, DO NOT MODIFY
+layout: learningpathall
+---
+
+## What you will build
+
+In this Learning Path, you will build a simple on-device AI fitness tutor for Android.
+
+The app watches a learner hold a plank, compares their body position with a stored instructor reference, asks a local LLM for one short correction, and speaks the correction using Android text-to-speech.
+
+This project is based on the [AI Yoga Tutor](https://developer.arm.com/community/arm-community-blogs/b/ai-blog/posts/ai-yoga-tutor) demo. The Learning Path keeps the same core pipeline, but narrows the app to one static pose so you can focus on how a pipeline that includes Android camera, pose detector, local LLM, and speech output fits together.
+
+
+
+The finished app has two main visual areas:
+
+- An instructor plank image on the left.
+- A live front-camera preview on the right.
+
+The app overlays a pose score and a short caption that matches the spoken coaching feedback.
+
+This Learning Path starts with a "shell" project with MediaPipe and camera integration mostly setup. If you wish to learn about that setup from an empty project, you could try another Learning Path - [Build a Hands-Free Selfie Android Application with MediaPipe](https://learn.arm.com/learning-paths/mobile-graphics-and-gaming/build-android-selfie-app-using-mediapipe-multimodality/) is a good example.
+
+## App pipeline
+
+The app uses a small pipeline of on-device components:
+
+```text
+reference image and camera view
+ -> CameraX live frames
+ -> Pose landmarks
+ -> joint-angle scoring
+ -> compact text prompt
+ -> Arm AI Chat + LLM
+ -> Text-To-Speech
+```
+
+Each stage passes structured data to a subsequent stage. The LLM does not receive camera frames or images. It receives a short text prompt describing the largest joint-angle differences between the learner and the reference plank pose.
+
+This keeps the LLM prompt small, reduces latency, and makes the behavior easier to tune.
+
+## Clone the starter project
+
+Clone the Learning Path code examples repository:
+
+```console
+git clone https://gitlab.arm.com/learning-code-examples/code-examples.git
+```
+
+The starter app for this Learning Path is in:
+
+```text
+code-examples/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/android
+```
+
+{{% notice Note %}}
+The starter project contains the app structure, layout, image asset, MediaPipe pose model, and several Kotlin shell files. You will fill in the missing code over the next pages.
+{{% /notice %}}
+
+## Open the project in Android Studio
+
+1. Start Android Studio.
+2. Select **Open**.
+3. Open `code-examples/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/android`.
+4. Wait for Gradle sync to finish.
+
+If Android Studio prompts you to trust the project, accept the prompt.
+
+The starter app is intentionally incomplete, but it should sync successfully before you add code.
+
+## Inspect the provided files
+
+Start by looking at the files that are already provided for you.
+
+Open `app/build.gradle` and confirm that the Android, CameraX, lifecycle, and MediaPipe dependencies are already present.
+Arm's AI Chat dependency is not included yet. You will add it later, when you implement local LLM inference.
+
+Open `app/src/main/AndroidManifest.xml` and confirm that the app requests camera access:
+
+```xml
+
+```
+
+Open `app/src/main/res/layout/activity_main.xml` and review the main UI. The layout already contains:
+
+- An `ImageView` for the instructor plank image.
+- A `PreviewView` for the live camera.
+- A score label.
+- A caption label for spoken feedback.
+
+Open `app/src/main/res/drawable/plank.jpg` and review the instructor reference image.
+
+Code is under the long path `app/src/main/java/com/arm/demo/AIPlankTutor`. Under that, open `data/PlankPoseData.kt` and note the hard-coded plank reference data. This file contains the instructor's reference landmarks and angle weights used by the scoring step. This was generated from the reference plank image in an offline step so it doesn't need any runtime compute.
+
+Android code starts from the `MainActivity.kt` file, and we will look at that in the next step.
diff --git a/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/2-camera-pose-landmarks.md b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/2-camera-pose-landmarks.md
new file mode 100644
index 0000000000..16a622d99f
--- /dev/null
+++ b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/2-camera-pose-landmarks.md
@@ -0,0 +1,240 @@
+---
+title: Get pose landmarks from camera
+weight: 3
+
+### FIXED, DO NOT MODIFY
+layout: learningpathall
+---
+
+## Objective
+
+In this section, you will connect the Android camera to MediaPipe Pose Landmarker.
+
+You will:
+
+- Bind a CameraX preview to the app UI.
+- Add an `ImageAnalysis` use case for live camera frames.
+- Configure MediaPipe Pose Landmarker in live-stream mode.
+- Convert each CameraX `ImageProxy` into a MediaPipe `MPImage`.
+- Send the first detected pose landmark list to `MainViewModel`.
+
+At the end of this section, the app opens the front camera and passes live pose landmarks into the app. The score will still be incomplete until you add pose scoring in the next section.
+
+## Configure CameraX
+
+Open `ui/MainActivity.kt`.
+
+The starter project already requests camera permission and calls `setUpCamera()` from `onCreate()`. Replace the TODO in `setUpCamera()` with the following code:
+
+```kotlin
+private fun setUpCamera() {
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
+ cameraProviderFuture.addListener(
+ {
+ cameraProvider = cameraProviderFuture.get()
+ bindCameraUseCases()
+ }, Dispatchers.Main.asExecutor()
+ )
+}
+```
+
+`ProcessCameraProvider` owns the camera use cases for the activity. When the provider is ready, the app stores it and calls `bindCameraUseCases()`.
+
+## Bind preview and image analysis
+
+The app needs two CameraX use cases:
+
+- `Preview`, which displays the camera feed in the `PreviewView`.
+- `ImageAnalysis`, which receives frames for pose detection.
+
+In `MainActivity.kt`, replace the TODO at the end of `bindCameraUseCases()` with this code:
+
+```kotlin
+val preview = Preview.Builder()
+ .setResolutionSelector(resolutionSelector)
+ .setTargetRotation(targetRotation)
+ .build()
+
+val imageAnalyzer = ImageAnalysis.Builder()
+ .setResolutionSelector(resolutionSelector)
+ .setTargetRotation(targetRotation)
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
+ .build()
+ .also {
+ it.setAnalyzer(
+ Dispatchers.Default.limitedParallelism(1).asExecutor()
+ ) { image -> detectPose(image) }
+ }
+
+cameraProvider.unbindAll()
+
+try {
+ cameraProvider.bindToLifecycle(
+ this, cameraSelector, preview, imageAnalyzer
+ )
+
+ preview.surfaceProvider = cameraPreview.surfaceProvider
+} catch (exc: Exception) {
+ Log.e(TAG, "Use case binding failed", exc)
+}
+```
+
+The analyzer uses `STRATEGY_KEEP_ONLY_LATEST` because pose detection should work on the most recent frame. If the device is busy, old frames are dropped instead of queued.
+
+The output image format is `RGBA_8888`, which makes the frame data easy to copy into a `Bitmap` before passing it to MediaPipe.
+
+## Send frames to the landmarker
+
+In `bindCameraUseCases()` we just set `ImageAnalysis` to call `detectPose()` for every analyzed frame. Now, replace the TODO in `detectPose()` with this code:
+
+```kotlin
+private fun detectPose(imageProxy: ImageProxy) {
+ if (!this::poseLandmarkerHelper.isInitialized || poseLandmarkerHelper.isClosed) {
+ imageProxy.close()
+ return
+ }
+
+ if (imageAnalysisEnabled) {
+ poseLandmarkerHelper.detectLiveStream(
+ imageProxy = imageProxy,
+ isFrontCamera = true
+ )
+ } else {
+ imageProxy.close()
+ }
+}
+```
+
+This code checks that the MediaPipe helper is ready before using it. If the helper is not ready, it closes the `ImageProxy` immediately.
+
+{{% notice Note %}}
+Every `ImageProxy` from CameraX must be closed. In this app, `PoseLandmarkerHelper.detectLiveStream()` closes the image after copying its pixels. If the frame is skipped, `detectPose()` closes it directly.
+{{% /notice %}}
+
+Now replace the TODO in `onResults()` with this code:
+
+```kotlin
+override fun onResults(landmarks: List?) {
+ mainViewModel.handleUserPose(landmarks)
+}
+```
+
+This is a callback from `PoseLandmarkerHelper` and sends the live landmarks onto the ViewModel. The next page will convert those landmarks into joint angles and a pose score.
+
+## Configure MediaPipe Pose Landmarker
+
+What happens between `detectPose()` and `onResults()`? Open `ui/landmarker/PoseLandmarkerHelper.kt`.
+
+The starter file already contains the MediaPipe imports, model path, confidence values, and the `LandmarkerListener` interface for the callbacks to `MainActivity`.
+
+Replace the TODO in `setupPoseLandmarker()` with this code:
+
+```kotlin
+fun setupPoseLandmarker(context: Context) {
+ try {
+ val baseOptions = BaseOptions.builder()
+ .setDelegate(Delegate.GPU)
+ .setModelAssetPath(MODEL_PATH)
+ .build()
+
+ val options = PoseLandmarker.PoseLandmarkerOptions.builder()
+ .setBaseOptions(baseOptions)
+ .setNumPoses(1)
+ .setMinPoseDetectionConfidence(MIN_POSE_DETECTION_CONFIDENCE)
+ .setMinTrackingConfidence(MIN_POSE_TRACKING_CONFIDENCE)
+ .setMinPosePresenceConfidence(MIN_POSE_PRESENCE_CONFIDENCE)
+ .setRunningMode(RunningMode.LIVE_STREAM)
+ .setResultListener(this::returnLiveStreamResult)
+ .setErrorListener(this::returnLiveStreamError)
+ .build()
+
+ poseLandmarker = PoseLandmarker.createFromOptions(context, options)
+ } catch (exception: IllegalStateException) {
+ listener.onError("Pose Landmarker failed to initialize. See logs for details.")
+ Log.e(TAG, "MediaPipe failed to load the pose landmarker", exception)
+ } catch (exception: RuntimeException) {
+ listener.onError("Pose Landmarker failed to initialize. See logs for details.")
+ Log.e(TAG, "MediaPipe failed to create the pose landmarker", exception)
+ }
+}
+```
+
+The app uses `RunningMode.LIVE_STREAM` because frames arrive continuously from the camera. In this mode, MediaPipe returns results through callbacks instead of returning them directly from the detection call.
+
+The app also requests one pose with `setNumPoses(1)`. This keeps the example focused on a single learner.
+
+## Convert camera frames to MPImage
+
+When we call from `MainActivity`, it is with a CameraX `ImageProxy`, but the MediaPipe analyzer expects an `MPImage`.
+
+Replace the TODO in `detectLiveStream()` with this code to convert between the two and start `poseLandmaker`'s analysis:
+
+```kotlin
+fun detectLiveStream(
+ imageProxy: ImageProxy,
+ isFrontCamera: Boolean
+) {
+ val frameTime = SystemClock.uptimeMillis()
+ val imageWidth = imageProxy.width
+ val imageHeight = imageProxy.height
+ val rotationDegrees = imageProxy.imageInfo.rotationDegrees
+
+ val bitmapBuffer = Bitmap.createBitmap(
+ imageWidth,
+ imageHeight,
+ Bitmap.Config.ARGB_8888
+ )
+
+ imageProxy.use { image ->
+ bitmapBuffer.copyPixelsFromBuffer(image.planes[0].buffer)
+ }
+
+ val matrix = Matrix().apply {
+ postRotate(rotationDegrees.toFloat())
+ if (isFrontCamera) {
+ postScale(-1f, 1f, imageWidth.toFloat(), imageHeight.toFloat())
+ }
+ }
+
+ val rotatedBitmap = Bitmap.createBitmap(
+ bitmapBuffer,
+ 0,
+ 0,
+ bitmapBuffer.width,
+ bitmapBuffer.height,
+ matrix,
+ true
+ )
+
+ val mpImage = BitmapImageBuilder(rotatedBitmap).build()
+ poseLandmarker?.detectAsync(mpImage, frameTime)
+}
+```
+
+The call to `imageProxy.use { ... }` closes the frame after its pixels are copied.
+
+The matrix rotates the image using the camera frame metadata. For the front camera, it also mirrors the image so the detected pose matches the preview the learner sees.
+
+## Return the first detected pose
+
+Finally, replace the TODO in `returnLiveStreamResult()` with this code:
+
+```kotlin
+private fun returnLiveStreamResult(
+ result: PoseLandmarkerResult,
+ @Suppress("UNUSED_PARAMETER") input: MPImage
+) {
+ listener.onResults(result.landmarks().firstOrNull())
+}
+```
+
+MediaPipe returns a list of detected poses. This app asks for one pose, so it forwards only the first landmark list.
+
+## Run the app
+
+Build and run the app on your Android device.
+
+When prompted, allow camera access. You should see the front camera preview in the right side of the app.
+
+The score will not update yet. At this point, the app is collecting pose landmarks and passing them to `MainViewModel`; the scoring logic is added in the next section.
\ No newline at end of file
diff --git a/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/3-plank-score.md b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/3-plank-score.md
new file mode 100644
index 0000000000..8247e0f950
--- /dev/null
+++ b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/3-plank-score.md
@@ -0,0 +1,197 @@
+---
+title: Score the plank pose
+weight: 4
+
+### FIXED, DO NOT MODIFY
+layout: learningpathall
+---
+
+## Objective
+
+In this section, you will turn MediaPipe pose landmarks into a simple plank score.
+
+You will:
+
+- Calculate joint angles from pose landmarks.
+- Compare the learner's live angles with the provided instructor reference.
+- Convert the weighted angle differences into a score from 0 to 100.
+- Display the score in the app UI.
+
+The starter project already includes `PlankPoseData.kt`. This file contains the reference landmarks for the instructor plank image and a list of angle weights. The weights let the app make some joints more important than others when calculating the final score.
+
+## Review the scoring data
+
+Open `data/PlankPoseData.kt`.
+
+The file provides three pieces of data:
+
+- `POSE_NAME`, which is the name used later in the LLM prompt.
+- `referenceLandmarks`, which stores the instructor plank pose as MediaPipe landmarks.
+- `angleWeights`, which stores the relative importance of each measured angle.
+
+You do not need to edit this file. In a larger app, you would capture this data from an instructor image or video as a preprocessing step, and load for different poses as needed. In this Learning Path, it is hard-coded so the Android app can stay focused on live inference and feedback.
+
+## Extract joint angles
+
+Open `ui/landmarker/PoseScoreHelper.kt`.
+
+The file already contains landmark index constants and `KEY_JOINTS_COORDINATES`. Each entry in `KEY_JOINTS_COORDINATES` identifies three landmarks that form one angle. For example, an elbow angle uses shoulder, elbow, and wrist landmarks.
+
+Replace the TODO in `extractAnglesFrom()` with this code:
+
+```kotlin
+fun extractAnglesFrom(landmarks: List): List {
+ val angles = KEY_JOINTS_COORDINATES.map { coords ->
+ calculateAngleFor(landmarks[coords[0]], landmarks[coords[1]], landmarks[coords[2]])
+ }.toMutableList()
+
+ val neck = midpoint(landmarks[IDX_L_SHOULDER], landmarks[IDX_R_SHOULDER])
+ val midHips = midpoint(landmarks[IDX_L_HIP], landmarks[IDX_R_HIP])
+ angles.add(calculateAngleFor(landmarks[IDX_NOSE], neck, midHips))
+
+ return angles
+}
+```
+
+The main list extracts wrist, elbow, shoulder, knee, ankle, and hip angles. The final angle estimates neck alignment by creating a midpoint between the shoulders and a midpoint between the hips, with the nose as the third landmark.
+
+Replace the TODO in `midpoint()` with this code for the neck joint helper function:
+
+```kotlin
+private fun midpoint(pointA: NormalizedLandmark, pointB: NormalizedLandmark) =
+ NormalizedLandmark.create(
+ (pointA.x() + pointB.x()) / 2,
+ (pointA.y() + pointB.y()) / 2,
+ (pointA.z() + pointB.z()) / 2
+ )
+```
+
+MediaPipe landmarks are normalized coordinates. The `x` and `y` values are relative to the input image, and `z` gives relative depth.
+
+## Calculate a 3D angle
+
+For each angle in `extractAnglesFrom()` it uses `calculateAngleFor()` - replace its TODO with this code:
+
+```kotlin
+private fun calculateAngleFor(
+ pointA: NormalizedLandmark,
+ pointB: NormalizedLandmark,
+ pointC: NormalizedLandmark
+): Double {
+ val bax = pointA.x() - pointB.x()
+ val bay = pointA.y() - pointB.y()
+ val baz = pointA.z() - pointB.z()
+
+ val bcx = pointC.x() - pointB.x()
+ val bcy = pointC.y() - pointB.y()
+ val bcz = pointC.z() - pointB.z()
+
+ val dotProduct = bax * bcx + bay * bcy + baz * bcz
+ val magnitudeBA = sqrt(bax.pow(2) + bay.pow(2) + baz.pow(2))
+ val magnitudeBC = sqrt(bcx.pow(2) + bcy.pow(2) + bcz.pow(2))
+ val cosine = (dotProduct / (magnitudeBA * magnitudeBC)).toDouble().coerceIn(-1.0, 1.0)
+
+ return Math.toDegrees(acos(cosine))
+}
+```
+
+This function calculates the angle at `pointB`. It treats `pointA - pointB` and `pointC - pointB` as two 3D vectors, then uses their dot product to calculate the angle between them.
+
+The `coerceIn(-1.0, 1.0)` call protects the `acos()` calculation from small floating-point rounding errors.
+
+## Calculate a weighted score
+
+Replace the TODO in `calculatePoseScore()` with this code:
+
+```kotlin
+fun calculatePoseScore(
+ correctAngles: List,
+ userAngles: List,
+ angleWeights: List,
+ maxDifference: Double = MAX_ANGLE_DIFFERENCE
+): Double {
+ require(correctAngles.isNotEmpty()) { "Correct angles cannot be empty." }
+ require(correctAngles.size == userAngles.size) { "Correct and user angle counts must match." }
+ require(correctAngles.size == angleWeights.size) { "Angle and weight counts must match." }
+
+ val weightedScoreSum = correctAngles.indices.sumOf { index ->
+ val difference = abs(correctAngles[index] - userAngles[index])
+ val normalizedScore = ((maxDifference - difference) / maxDifference)
+ .coerceIn(0.0, 1.0) * HIGHEST_SCORE
+ normalizedScore * angleWeights[index]
+ }
+
+ return weightedScoreSum / angleWeights.sum().toDouble()
+}
+```
+
+For each angle, the score starts at 100 and drops as the learner's angle differs from the reference angle. Differences at or above `MAX_ANGLE_DIFFERENCE` contribute zero for that angle.
+
+The final score is the weighted average across all measured angles.
+
+{{% notice Note %}}
+This score is a simple pose-matching signal for the demo app. It is not a clinical, safety, or fitness-quality assessment.
+{{% /notice %}}
+
+## Emit score data from the ViewModel
+
+Open `ui\viewmodels\MainViewModel.kt`.
+
+Replace the TODO inside the `userPoseResults` mapping block with this code:
+
+```kotlin
+val referenceAngles = extractAnglesFrom(PlankPoseData.referenceLandmarks)
+val userAngles = extractAnglesFrom(userLandmarks)
+val score = calculatePoseScore(
+ correctAngles = referenceAngles,
+ userAngles = userAngles,
+ angleWeights = PlankPoseData.angleWeights
+)
+
+UserPoseResult(referenceAngles, userAngles, score)
+```
+
+This code calculates angles for the instructor reference and the learner's live pose, then stores both angle lists with the score. The next page will reuse the angle lists to build the LLM prompt.
+
+Now replace the TODO in `userPoseScore` with this code:
+
+```kotlin
+val userPoseScore: Flow =
+ sharedUserPoseResults.map { it.scoreVal.roundToInt().toString() }
+```
+
+The UI wants a rounded string score. The underlying `UserPoseResult` keeps the full `Double` value for later use.
+
+## Display the score
+
+Open `ui\MainActivity.kt`.
+
+The starter project keeps `collectAppState()` as a safe no-op so the app can run before scoring and speech are implemented. Replace that function with this version:
+
+```kotlin
+private suspend fun collectAppState() = coroutineScope {
+ launch {
+ mainViewModel.userPoseScore.collect {
+ score.text = getString(R.string.score) + it
+ }
+ }
+}
+```
+
+Add this import near the other coroutine imports:
+
+```kotlin
+import kotlinx.coroutines.coroutineScope
+```
+
+The existing `repeatOnLifecycle(Lifecycle.State.STARTED)` block already calls `collectAppState()`. Changing the function to `suspend` is valid because `repeatOnLifecycle` runs its block from a coroutine.
+
+Later sections will add more `launch { ... }` collectors inside this same `coroutineScope` block.
+
+## Run the app
+
+Build and run the app on your Android device.
+
+When you move in front of the camera, the score should update as MediaPipe detects your landmarks. The score will vary with camera angle and distance from the device, but mainly with how closely your body position matches the reference plank pose.
+
+At this point, the app can detect and score the plank pose. The next section converts the largest angle differences into a short text prompt for the local LLM.
diff --git a/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/4-data-to-prompt.md b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/4-data-to-prompt.md
new file mode 100644
index 0000000000..c8f4250965
--- /dev/null
+++ b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/4-data-to-prompt.md
@@ -0,0 +1,159 @@
+---
+title: Turn pose data into a prompt
+weight: 5
+
+### FIXED, DO NOT MODIFY
+layout: learningpathall
+---
+
+## Objective
+
+In this section, you will turn pose and score data into a compact text prompt for the local LLM.
+
+You will:
+
+- Compare the instructor and learner angle lists.
+- Sort the largest joint-angle differences.
+- Filter out small differences so the prompt stays short.
+- Format the remaining differences as plain text.
+- Expose the generated prompt from `MainViewModel`.
+
+The LLM will not receive images or raw landmarks. It will receive a short list of the largest pose differences, such as:
+
+```text
+I'm doing Plank pose. My joints have the following significant angle differences to the teacher's: Left hip flexion: -18, Right shoulder: 12
+```
+
+This keeps the prompt small and gives the model only the facts it needs to produce one coaching cue.
+
+## Format the largest angle differences
+
+Open `ui/landmarker/PoseScoreHelper.kt`.
+
+The file already contains `KEY_ANGLE_NAMES`, which maps each calculated angle to a human-readable joint name. The order of `KEY_ANGLE_NAMES` matches the order of the angles returned by `extractAnglesFrom()`.
+
+Replace the TODO in `angleDifference()` with this code:
+
+```kotlin
+fun angleDifference(
+ correctAngles: List,
+ userAngles: List,
+ filter: Double = -1.0,
+ maxEntries: Int = Int.MAX_VALUE
+): Map {
+ require(correctAngles.isNotEmpty()) { "Correct angles cannot be empty." }
+ require(correctAngles.size == userAngles.size) { "Correct and user angle counts must match." }
+
+ return correctAngles.zip(userAngles)
+ .zip(KEY_ANGLE_NAMES) { (correct, user), name ->
+ val difference = correct - user
+ Triple(name, difference, "%.0f".format(difference))
+ }
+ .filter { (_, difference, _) -> filter <= 0 || abs(difference) >= filter }
+ .sortedByDescending { abs(it.second) }
+ .take(maxEntries)
+ .associate { (name, _, formatted) -> name to formatted }
+}
+```
+
+This function pairs each reference angle with the corresponding learner angle, calculates the difference, and sorts by the absolute size of that difference.
+
+The returned map uses the joint name as the key and a rounded string angle difference as the value. The string value is useful because the next function can insert it directly into the prompt.
+
+{{% notice Note %}}
+The sign of the difference is important. A positive value means the learner needs to straighten that joint compared with the reference. A negative value means the learner needs to bend it more.
+{{% /notice %}}
+
+## Generate the LLM prompt
+
+Replace the TODO in `generateLlmPromptFrom()` with this code:
+
+```kotlin
+fun generateLlmPromptFrom(
+ referenceAngles: List,
+ userAngles: List,
+ poseName: String
+): String {
+ val filteredAngles = angleDifference(
+ referenceAngles,
+ userAngles,
+ JOINT_DIFF_FILTER,
+ MAX_ENTRIES
+ )
+
+ if (filteredAngles.isEmpty()) {
+ return "I'm doing $poseName pose. My joint angles are close to the teacher's reference; give me one brief plank alignment cue."
+ }
+
+ val angleSummary = filteredAngles.entries.joinToString { (name, angleDiff) ->
+ "$name: $angleDiff"
+ }
+ return "I'm doing $poseName pose. My joints have the following significant angle differences to the teacher's: $angleSummary"
+}
+```
+
+This function calls `angleDifference()` with values that ensure it filters out small differences using `JOINT_DIFF_FILTER` and limits the prompt to `MAX_ENTRIES` joint differences.
+
+The fallback prompt handles the case where the learner is already close to the reference pose. The LLM still creates a useful instruction, but it is not forced to invent a large correction from tiny angle changes. In the full AI Yoga Tutor this fallback won't be hit as a different "praise" prompt is given when the score is high (greater than 70).
+
+## Emit prompts from the ViewModel
+
+Open `ui\viewmodels\MainViewModel.kt`.
+
+Replace the TODO in `userPosePrompt` with this code:
+
+```kotlin
+val userPosePrompt: Flow> =
+ sharedUserPoseResults
+ .filter { !_isSpeaking.value }
+ .map { userResult ->
+ generateLlmPromptFrom(
+ referenceAngles = userResult.referenceAngles,
+ userAngles = userResult.userAngles,
+ poseName = poseName
+ ) to userResult.scoreVal
+ }
+ .flowOn(Dispatchers.Default)
+```
+
+The prompt is paired with the score because fuller app versions might use both values when deciding what feedback is spoken. In this Learning Path, the LLM step uses the prompt text.
+
+The `_isSpeaking` filter prevents the app from generating more feedback while the previous correction is still being spoken. Speech is added later, but keeping the filter here makes the data flow ready for that final step.
+
+## Check the prompt in Logcat
+
+The local LLM is added in the next section. For now, add a temporary collector so you can see the prompt in Logcat.
+
+Open `MainActivity.kt` and update `collectAppState()` so it also collects `userPosePrompt`:
+
+```kotlin
+private suspend fun collectAppState() = coroutineScope {
+ launch {
+ mainViewModel.userPoseScore.collect {
+ score.text = getString(R.string.score) + it
+ }
+ }
+
+ launch {
+ mainViewModel.userPosePrompt.collect { prompt ->
+ Log.d(TAG, "Pose prompt: ${prompt.first}")
+ }
+ }
+}
+```
+
+This Logcat collector is only for checking this section. In the next section, you will replace it with code that sends the prompt to the local LLM.
+
+## Run the app
+
+Build and run the app on your Android device.
+
+Open Logcat and filter for `MainActivity`. As you move in front of the camera, you should see short prompts that describe the largest differences from the reference plank pose.
+
+The app now has the complete input side of the tutor pipeline:
+
+```text
+Camera frame -> pose landmarks -> angle score -> text prompt
+```
+
+In the next section, you will add Arm's AI Chat library and send this prompt to a local LLM.
\ No newline at end of file
diff --git a/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/5-llm-ai-chat.md b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/5-llm-ai-chat.md
new file mode 100644
index 0000000000..09d50652c4
--- /dev/null
+++ b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/5-llm-ai-chat.md
@@ -0,0 +1,279 @@
+---
+title: Run the LLM locally with AI Chat
+weight: 6
+
+### FIXED, DO NOT MODIFY
+layout: learningpathall
+---
+
+## Objective
+
+In this section, you will add Arm's AI Chat library and use it to run a local GGUF model on the Android device.
+
+You will:
+
+- Add the AI Chat dependency.
+- Import the Phi-4-mini GGUF model file onto the device.
+- Create an AI Chat inference engine.
+- Load the model and set the tutor system prompt.
+- Send the pose prompt from the previous section to the local LLM.
+- Convert streamed LLM tokens into complete `Sentence` objects.
+
+At the end of this section, the app will produce short text coaching corrections and show them as captions. Speech output is added in the next section.
+
+## Add the AI Chat dependency
+
+Open `app/build.gradle`.
+
+Add the AI Chat dependency to the end of the `dependencies` block:
+
+```groovy
+implementation("com.arm:ai-chat:0.1.0")
+```
+
+Sync the project with Gradle.
+
+The AI Chat library provides the Android inference API used by this project and by the [Arm AI Chat app](https://play.google.com/store/apps/details?id=com.arm.aichat&hl=en_GB). It uses `llama.cpp` for GGUF inference, and `llama.cpp` integrates Arm [KleidiAI](https://developer.arm.com/ai/kleidi-libraries) kernels. Q4_0 GGUF models work particularly well with these kernels and can get strong acceleration on phones with [SME2](https://www.arm.com/technologies/sme2), SVE2, and Neon support.
+
+## Add the model file to the device
+
+The app expects a Q4_0 GGUF model file named:
+
+```text
+Phi-4-mini-instruct-Q4_0.gguf
+```
+
+Download [microsoft_Phi-4-mini-instruct-Q4_0.gguf](https://huggingface.co/bartowski/microsoft_Phi-4-mini-instruct-GGUF/blob/main/microsoft_Phi-4-mini-instruct-Q4_0.gguf) from Hugging Face. The model is 3.8B parameters, and around 2.2GB. Remove the "microsoft_" off the front of the name.
+
+The provided `LlmModelStore.kt` helper looks for the model in a few import locations. The most useful location while developing is the app-specific external files directory:
+
+```text
+/sdcard/Android/data/com.arm.demo.AIPlankTutor/files/llm/Phi-4-mini-instruct-Q4_0.gguf
+```
+
+With your device connected over USB, create the directory:
+
+```console
+adb shell mkdir -p /sdcard/Android/data/com.arm.demo.AIPlankTutor/files/llm
+```
+
+Then push the model file:
+
+```console
+adb push Phi-4-mini-instruct-Q4_0.gguf /sdcard/Android/data/com.arm.demo.AIPlankTutor/files/llm/
+```
+
+When the app first loads the model, `LlmModelStore` copies it into the app's internal files directory. Later runs use the internal copy.
+
+{{% notice Note %}}
+If the model cannot be found, check Logcat for `LlmModelStore`. The error message lists every model path that was checked.
+{{% /notice %}}
+
+## Wire AI Chat into MainActivity
+
+Open `ui/MainActivity.kt`.
+
+Add these imports:
+
+```kotlin
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewmodel.CreationExtras
+import com.arm.aichat.AiChat
+import com.arm.demo.AIPlankTutor.llm.LlmModelStore
+import com.arm.demo.AIPlankTutor.ui.viewmodels.LlmViewModel
+```
+
+Add an `llmViewModel` property below the existing `mainViewModel` property:
+
+```kotlin
+private val mainViewModel: MainViewModel by viewModels()
+private val llmViewModel: LlmViewModel by viewModels {
+ object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class, extras: CreationExtras): T {
+ val inferenceEngine = AiChat.getInferenceEngine(applicationContext)
+ val modelStore = LlmModelStore(applicationContext)
+ return LlmViewModel(inferenceEngine, modelStore) as T
+ }
+ }
+}
+```
+
+`AiChat.getInferenceEngine()` creates the inference engine used by the ViewModel. `LlmModelStore` is the helper that finds or imports the GGUF model file. Now we need to make the `LlmViewModel` accept those parameters.
+
+## Add AI Chat to LlmViewModel
+
+Open `LlmViewModel.kt`.
+
+Add these imports:
+
+```kotlin
+import android.util.Log
+import androidx.lifecycle.viewModelScope
+import com.arm.aichat.InferenceEngine
+import com.arm.demo.AIPlankTutor.llm.LlmModelStore
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.launch
+```
+
+Change the class declaration so the ViewModel receives the AI Chat inference engine and model store:
+
+```kotlin
+class LlmViewModel(
+ private val inferenceEngine: InferenceEngine,
+ private val llmModelStore: LlmModelStore,
+) : ViewModel() {
+```
+
+Add this `init` block at the very start of the class body:
+
+```kotlin
+init {
+ viewModelScope.launch {
+ _isBusy.emit(true)
+ loadModelAndSystemPrompt()
+ _isBusy.emit(false)
+ _systemPromptProcessed.emit(true)
+ }
+}
+```
+
+When the ViewModel is created, this loads the model and sends the system prompt. The existing `_isBusy` state will also be used to avoid starting another LLM generation while one is already running.
+
+## Load the model and system prompt
+
+Replace the TODO in `loadModelAndSystemPrompt()` with this code:
+
+```kotlin
+private suspend fun loadModelAndSystemPrompt() {
+ val modelFile = llmModelStore.getModelFile()
+ inferenceEngine.loadModel(modelFile.absolutePath)
+ inferenceEngine.setSystemPrompt(TUTOR_SYSTEM_PROMPT)
+}
+```
+
+You can read `TUTOR_SYSTEM_PROMPT` at the bottom of the file. The system prompt tells the model to act as a concise yoga teacher, use the joint-angle facts, and return one short correction.
+
+## Send a prompt to AI Chat
+
+Replace the TODO in `sendUserPrompt()` with this code:
+
+```kotlin
+fun sendUserPrompt(
+ plainTextMessage: String,
+ generationLimit: Int = LLM_GENERATION_LIMIT,
+) {
+ if (_isBusy.value) return
+
+ viewModelScope.launch {
+ if (_isBusy.value) return@launch
+
+ Log.d(TAG, "Sending:\n$plainTextMessage")
+
+ _isBusy.emit(true)
+
+ var tokenCount = 0
+
+ inferenceEngine.sendUserPrompt(plainTextMessage, predictLength = generationLimit)
+ .onCompletion { cause ->
+ _isBusy.emit(false)
+ if (sentence.isNotEmpty()) {
+ _tokens.emit(".")
+ }
+ if (cause != null) {
+ Log.d(TAG, "> Token collection aborted! $cause")
+ } else if (tokenCount == 0) {
+ Log.d(TAG, "> No tokens collected!")
+ } else {
+ Log.d(TAG, "> Token collection complete.")
+ }
+ }
+ .collect { token ->
+ tokenCount++
+ _tokens.emit(token)
+ }
+ }
+}
+```
+
+The two `_isBusy` checks prevent the app from starting a second generation if a new prompt arrives just before image analysis is disabled. The `onCompletion` block emits a final full stop if the model stops before ending the current sentence. That lets the sentence splitter flush the last partial sentence.
+
+## Split streamed tokens into sentences
+
+AI Chat returns a stream of tokens. Android TTS works better with complete phrases or sentences, so split the stream before page 6 consumes it.
+
+Replace the TODO in the `sentences` Flow with this code:
+
+```kotlin
+val sentences: Flow = _tokens
+ .filterNot { it.isNewLine() }
+ .map { word ->
+ if (sentence.isEmpty()) {
+ if (word.isPunctuation()) return@map null
+ }
+
+ sentence.append(if (sentence.isEmpty()) word.trimStart() else word)
+
+ if (sentence.isNotEmpty() && word.isPunctuation()) {
+ sentence.run { toString().also { setLength(0) } }
+ } else null
+ }
+ .filterNotNull()
+ .map { text -> Sentence(text) }
+ .flowOn(Dispatchers.Default)
+```
+
+The ViewModel now exposes a `Flow` instead of raw tokens. Page 6 will collect this Flow and send each sentence to Android text-to-speech.
+
+## Send pose prompts to the LLM
+
+Return to `MainActivity.kt`.
+
+In the previous section, you logged each pose prompt to Logcat. Replace that temporary prompt collector in `collectAppState()` with this version:
+
+```kotlin
+launch {
+ mainViewModel.userPosePrompt.collect {
+ if (imageAnalysisEnabled && llmViewModel.systemPromptProcessed.value) {
+ llmViewModel.sendUserPrompt(it.first)
+ }
+ }
+}
+```
+
+Add another collector to pause image analysis while the model is busy:
+
+```kotlin
+launch {
+ llmViewModel.isBusy.collect {
+ imageAnalysisEnabled = !it
+ }
+}
+```
+
+This prevents the app from generating a new prompt for every camera frame while the LLM is already responding.
+
+Finally, add a temporary sentence collector so you can show the generated correction in the caption area and check it in Logcat:
+
+```kotlin
+launch {
+ llmViewModel.sentences.collect { sentence ->
+ captionTextView.text = sentence.text
+ captionTextView.visibility = View.VISIBLE
+ Log.d(TAG, "Tutor correction: ${sentence.text}")
+ }
+}
+```
+
+The next section will replace this direct caption update with `SpeechManager`, so the caption stays synchronized with Android text-to-speech playback.
+
+## Run the app
+
+Build and run the app on your Android device. You should see the tutor's advice now at the bottom of the screen.
+
+To see what's happening behind the scenes, open Logcat and filter for `LlmViewModel` or `MainActivity`. The app should load the model, send pose prompts, and print short tutor corrections.
+
+The first run can take longer because the model file may be copied into the app's internal storage and loaded into memory. Later runs should start faster.
+
+At this point, the app can generate local text feedback. The next section uses Android `TextToSpeech` to speak each completed sentence and show it as a caption.
diff --git a/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/6-android-tts.md b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/6-android-tts.md
new file mode 100644
index 0000000000..27c03dd06f
--- /dev/null
+++ b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/6-android-tts.md
@@ -0,0 +1,158 @@
+---
+title: Speak corrections with Android TTS
+weight: 7
+
+### FIXED, DO NOT MODIFY
+layout: learningpathall
+---
+
+## Objective
+
+In this section, you will use Android's built-in `TextToSpeech` engine to speak each coaching correction from the local LLM.
+
+You will:
+
+- Initialize `TextToSpeech`.
+- Register an `UtteranceProgressListener`.
+- Queue each correction with `TextToSpeech.QUEUE_ADD`.
+- Keep the caption synchronized with the phrase currently being spoken.
+- Connect `LlmViewModel.sentences` to `SpeechManager` in `MainActivity`.
+
+At the end of this section, the app will speak each complete correction and show the same text as a caption while it is being spoken.
+
+## Initialize TextToSpeech
+
+Open `ui/SpeechManager.kt`.
+
+The starter file already includes the imports, state flows, and helper functions needed. The two pieces of state exposed by the class are:
+
+- `idle`: whether Android TTS has finished speaking all queued phrases.
+- `currentCaption`: the text that should be shown at the bottom of the screen.
+
+Replace the TODO in the `init` block with this code:
+
+```kotlin
+init {
+ tts = TextToSpeech(context.applicationContext) { status ->
+ if (status == TextToSpeech.SUCCESS) {
+ tts.language = Locale.getDefault()
+ } else {
+ Log.w(TAG, "TextToSpeech initialization failed with status $status")
+ }
+ }
+
+ tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
+ override fun onStart(utteranceId: String?) {
+ utteranceId?.let { id -> getQueuedCaption(id)?.let { setCaption(it) } }
+ setIdle(false)
+ }
+
+ override fun onDone(utteranceId: String?) {
+ finishUtterance(utteranceId)
+ }
+
+ @Suppress("OVERRIDE_DEPRECATION")
+ override fun onError(utteranceId: String?) {
+ finishUtterance(utteranceId)
+ }
+ })
+}
+```
+
+The `TextToSpeech` callback sets the default device language when initialization succeeds. The `UtteranceProgressListener` is called by Android as each spoken phrase starts and finishes.
+
+The listener callbacks are not guaranteed to run on the main thread, so the provided `setCaption()` and `setIdle()` helpers update the flows through a main-thread coroutine.
+
+## Queue speech
+
+Replace the TODO in `launchSpeechGenerationAndPlaybackJob()` with this code:
+
+```kotlin
+fun launchSpeechGenerationAndPlaybackJob(speech: String) {
+ if (speech.isBlank()) return
+
+ val utteranceId = UUID.randomUUID().toString()
+ queueUtterance(utteranceId, speech)
+ setIdle(false)
+ val result = tts.speak(speech, TextToSpeech.QUEUE_ADD, null, utteranceId)
+ if (result == TextToSpeech.ERROR) {
+ Log.w(TAG, "TextToSpeech failed to queue utterance")
+ finishUtterance(utteranceId)
+ }
+}
+```
+
+`TextToSpeech.QUEUE_ADD` is important here. The LLM output is streamed and split into complete sentences, so the app might receive another sentence while Android is still speaking the previous one. `QUEUE_ADD` makes Android speak the phrases in order instead of interrupting the current phrase.
+
+Each phrase is queued with an utterance ID. `SpeechManager` keeps a small map from utterance ID to caption text so the app can show the caption only when that phrase starts speaking.
+
+## Finish each utterance
+
+Replace the TODO in `finishUtterance()` with this code:
+
+```kotlin
+private fun finishUtterance(utteranceId: String?) {
+ val queueIsEmpty = synchronized(queuedUtterances) {
+ utteranceId?.let { queuedUtterances.remove(it) }
+ queuedUtterances.isEmpty()
+ }
+
+ setCaption("")
+ setIdle(queueIsEmpty)
+}
+```
+
+When Android reports that an utterance has finished, this removes it from the queue map. The same helper is used if `tts.speak()` fails to queue an utterance. If no queued utterances remain, `idle` becomes `true`.
+
+The existing `shutdown()` function stops and releases Android TTS when the activity is destroyed.
+
+## Connect SpeechManager to MainActivity
+
+Open `ui/MainActivity.kt`.
+
+In the last section, you added a temporary sentences collector that put each LLM sentence directly into the caption view and logged it. Now replace that collector with this version:
+
+```kotlin
+launch {
+ llmViewModel.sentences.collect { sentence ->
+ mainViewModel.setSpeaking(true)
+ speechManager.launchSpeechGenerationAndPlaybackJob(sentence.text)
+ }
+}
+```
+
+This sends each completed LLM sentence to Android TTS instead of writing it directly to the caption view.
+
+Next, add a collector for `speechManager.idle`:
+
+```kotlin
+launch {
+ speechManager.idle.collect { idle ->
+ mainViewModel.setSpeaking(!idle)
+ }
+}
+```
+
+`MainViewModel` uses this state to avoid asking the LLM for another correction while the current spoken feedback is still playing.
+
+Finally, add a collector for `speechManager.currentCaption`:
+
+```kotlin
+launch {
+ speechManager.currentCaption.collect { caption ->
+ captionTextView.text = caption
+ captionTextView.visibility =
+ if (caption.isBlank()) View.INVISIBLE else View.VISIBLE
+ }
+}
+```
+
+The caption is now controlled by Android TTS playback. It appears when Android starts speaking a phrase and disappears when that phrase finishes.
+
+## Run the app
+
+Build and run the app on your Android device.
+
+Move into a plank position in front of the camera. The score should update, the local LLM should generate a short correction, and Android TTS should speak it. The same correction should appear as a caption while it is being spoken.
+
+At this point, the app has the full on-device pipeline: camera input, pose landmarks, joint-angle scoring, local LLM feedback, and spoken output.
diff --git a/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/7-extend.md b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/7-extend.md
new file mode 100644
index 0000000000..9d03b7e301
--- /dev/null
+++ b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/7-extend.md
@@ -0,0 +1,155 @@
+---
+title: Tune and extend the tutor
+weight: 8
+
+### FIXED, DO NOT MODIFY
+layout: learningpathall
+---
+
+## Possible improvements
+
+You now have a complete on-device AI plank tutor. It detects pose landmarks from the camera, compares the learner against an instructor reference, prompts a local LLM, and speaks the result with Android text-to-speech.
+
+This section outlines practical ways to tune the experience and extend it beyond the simple single-pose demo.
+
+We will go through:
+
+- Which app parameters are worth tuning.
+- How model, prompt, and feedback choices affect latency.
+- Why fine-tuning can improve coaching quality.
+- What a high-level fine-tuning workflow looks like.
+- How the full Yoga Tutor app expands this simple pipeline.
+
+## Tune the feedback loop
+
+The demo is intentionally small, so the easiest improvements are in the constants and prompt text you have already used.
+
+In `MainViewModel.kt`, tune how often pose data is scored:
+
+```kotlin
+private const val USER_POSE_SCORING_INTERVAL = 500L
+```
+
+A shorter interval makes the UI feel more responsive, but it can also produce more LLM prompts. A longer interval reduces work and can make coaching feel calmer.
+
+In `PoseScoreHelper.kt`, tune how much pose difference is included in the prompt:
+
+```kotlin
+private const val JOINT_DIFF_FILTER = 8.0
+private const val MAX_ENTRIES = 5
+```
+
+Increasing `JOINT_DIFF_FILTER` sends only larger joint-angle differences to the LLM. Reducing `MAX_ENTRIES` makes the prompt shorter and usually reduces inference time, but gives the model less context.
+
+You can also adjust the joint list and weights in `PoseScoreHelper.kt` and `PlankPoseData.kt`. For example, a plank tutor might weight hips, shoulders, and neck alignment more strongly than wrists.
+
+## Tune the LLM response
+
+In `LlmViewModel.kt`, tune how much text the model can generate:
+
+```kotlin
+private const val LLM_GENERATION_LIMIT: Int = 128
+```
+
+For this app, shorter is usually better. The tutor only needs one correction at a time, so a smaller limit can reduce latency and avoid long coaching messages.
+
+The system prompt is the other important control:
+
+```kotlin
+private const val TUTOR_SYSTEM_PROMPT = "You are a friendly..."
+```
+
+You can use the system prompt to make the output more direct, more encouraging, or more safety-focused. Keep the prompt specific. This app works best when the model is told to give one short correction and avoid exposing the numeric joint-angle differences.
+
+The model choice also affects the experience. Smaller models usually load and respond faster, while larger models can give better language quality. Quantization affects both speed and quality. The Learning Path used a Q4_0 GGUF model because it is a practical balance for mobile inference, and gets good speed-up from Kleidi AI kernels.
+
+## Improve feedback quality with fine-tuning
+
+Prompt engineering can make a general instruction model behave reasonably well, but fitness coaching has a narrow style requirement. The ideal response is short, natural, safe, and physically meaningful. Fine-tuning a small model on examples from this domain can make the output much more consistent.
+
+A useful fine-tuning dataset should contain pairs like this:
+
+```json
+{
+ "input": "I'm doing Plank pose. My joints have the following significant angle differences to the teacher's: Left hip flexion: -18, Right shoulder: 12, Neck: 10.",
+ "output": "Lift your hips slightly."
+}
+```
+
+The output should be the kind of phrase you want the app to speak. Avoid numbers, long explanations, and multiple corrections in one answer.
+
+A high-level workflow is:
+
+1. Define the input format.
+
+ Keep it close to the prompt generated by `PoseScoreHelper.generateLlmPromptFrom()`. Include the pose name and the most important joint-angle differences.
+
+2. Define the output style.
+
+ Write rules for the coaching phrase: one short cue, no numeric angles, no diagnosis, no unsafe claims, and no correction for joints that are not in the input.
+
+3. Generate a seed dataset.
+
+ Start with examples you write manually. Cover common plank problems such as sagging hips, lifted hips, bent knees, rounded shoulders, bent elbows, dropped head, and uneven weight.
+
+4. Expand the dataset with a script.
+
+ Use a Python script to generate many input variations and call an LLM API to draft matching coaching phrases. For example, the script can choose one to three joint differences, vary the angle sizes, mirror left and right sides, and combine related issues such as hip sag with shoulder position. Send each generated input to the OpenAI API and ask for a structured JSON response containing one safe spoken correction. Structured outputs and batch-style requests are useful when creating larger generated datasets.
+
+5. Review and clean the generated data.
+
+ This is the most important step. Remove responses that mention numbers, give multiple corrections, sound unnatural when spoken, or make unsafe assumptions. The dataset will often be too big to go through all of it, so generate a small dataset first to determine quality. Examine a signficant section of it and determine if it needs re-generating with adjusted rules.
+
+6. Split the dataset.
+
+ Keep separate training, validation, and test sets. The test set should include examples from real app prompts, not only generated prompts.
+
+7. Fine-tune the model.
+
+ Use supervised fine-tuning or a LoRA/QLoRA workflow on a model that can later run on the device. Train on prompt-response examples that match the app's runtime format. There are a number of good frameworks to work with, so this doesn't need setting up from scratch.
+
+8. Convert and quantize for Android.
+
+ Convert the fine-tuned model to GGUF, then quantize it to a mobile-friendly format such as Q4_0. Test quality again after quantization because quantization can change wording and consistency.
+
+9. Evaluate on device.
+
+ Run the app with real camera input. Check whether the model gives short, useful corrections, whether latency is acceptable, and whether repeated prompts produce varied but consistent feedback.
+
+Fine-tuning is not included as a formal step in this Learning Path because it adds dataset design, training infrastructure, model conversion, quantization, and evaluation. However it will make a significant quality difference to the app, as you get much better responses. Treat it as a follow-up project once the app pipeline is working.
+
+## Extend beyond one pose
+
+The full Yoga Tutor app uses the same broad pipeline, but adds more product features around it.
+
+To support multiple poses, replace the single `PlankPoseData.kt` object with a pose data repository that can load data from files. Each pose needs a name, instructor reference landmarks, joint weights, and display media. The prompt should include the current pose name so the LLM can choose language that fits the pose.
+
+To show richer instructor guidance, replace `plank.jpg` with pose-specific images or videos. If you add videos, you will also need lifecycle handling for playback and a way to keep the instructor media synchronized with the current pose.
+
+To support transitions between poses, add transition states to the app. Transitions need different feedback rules from static holds: the tutor might give timing, breathing, or movement cues rather than strict alignment corrections.
+
+To make the tutor feel more natural, add different message types. For example:
+
+- Correction when the score is low.
+- Praise when the score is high.
+- Encouragement or generaic yoga advice when correction or praise might feel repetitive.
+- A short rest or breathing cue between corrections.
+
+To make the app more robust, handle interruptions and edge cases. For example, decide what should happen when the learner leaves the camera view, when no pose is detected, when the LLM is still responding, or when TTS is still speaking.
+
+To improve voice quality, you can replace Android's built-in `TextToSpeech` with a higher-quality neural TTS engine. That adds model size, latency and runtime complexity, so Android TTS is a good starting point for this Learning Path.
+
+## Summary
+
+You built a complete on-device AI pipeline for real-time coaching:
+
+```text
+CameraX frames
+ -> MediaPipe pose landmarks
+ -> joint-angle scoring
+ -> compact prompt
+ -> local GGUF model with AI Chat
+ -> Android TextToSpeech
+```
+
+The same structure can be adapted to other fitness, movement, accessibility, or training apps, and a slightly different computer vision input pipeline will have many other use cases. The key design choice is to keep each model doing the job it is good at: pose estimation extracts structured body data, scoring converts it into compact facts, and the LLM turns those facts into human-friendly coaching.
diff --git a/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/_index.md b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/_index.md
new file mode 100644
index 0000000000..88ed016996
--- /dev/null
+++ b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/_index.md
@@ -0,0 +1,74 @@
+---
+title: Build an on-device AI Plank Tutor on Android
+
+draft: true
+cascade:
+ draft: true
+
+minutes_to_complete: 90
+
+who_is_this_for: This Learning Path is for Android developers who want to explore creating an ML/GenAI pipeline, including camera input, local LLM inference, and speech.
+
+learning_objectives:
+ - Detect human pose landmarks from live Android camera frames with MediaPipe.
+ - Structure data and build compact prompts that turn raw data into useful LLM input.
+ - Run a mobile-sized LLM on-device with Arm's AI Chat library.
+ - Speak generated output with Text-To-Speech.
+
+prerequisites:
+ - A development machine with Android Studio installed.
+ - A recent Arm-powered Android phone in Developer Mode, with a USB data cable.
+ - Basic familiarity with Kotlin and Android app development
+
+author: Ben Clark
+
+### Tags
+skilllevels: Introductory
+subjects: ML
+armips:
+ - Cortex-A
+ - Arm AI Chat library
+tools_software_languages:
+ - Android Studio
+ - Kotlin
+ - CameraX
+ - MediaPipe
+ - LLM
+ - Neon
+ - SVE2
+ - SME2
+operatingsystems:
+ - Android
+
+further_reading:
+ - resource:
+ title: AI Yoga Tutor
+ link: https://developer.arm.com/community/arm-community-blogs/b/ai-blog/posts/ai-yoga-tutor
+ type: blog
+ - resource:
+ title: AI Chat - Explore and evaluate LLMs on Android and ChromeOS
+ link: https://developer.arm.com/community/arm-community-blogs/b/announcements/posts/ai-chat-explore-and-evaluate-llms-on-android-and-chromeos
+ type: blog
+ - resource:
+ title: AI Chat library on GitHub
+ link: https://github.com/arm/ai-chat
+ type: website
+ - resource:
+ title: AI Chat library @ Maven Central
+ link: https://central.sonatype.com/artifact/com.arm/ai-chat
+ type: documentation
+ - resource:
+ title: MediaPipe Pose Landmarker
+ link: https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker
+ type: documentation
+ - resource:
+ title: Android TextToSpeech
+ link: https://developer.android.com/reference/android/speech/tts/TextToSpeech
+ type: documentation
+
+### FIXED, DO NOT MODIFY
+# ================================================================================
+weight: 1 # _index.md always has weight of 1 to order correctly
+layout: "learningpathall" # All files under learning paths have this same wrapper
+learning_path_main_page: "yes" # This should be surfaced when looking for related content. Only set for _index.md of learning path content.
+---
diff --git a/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/_next-steps.md b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/_next-steps.md
new file mode 100644
index 0000000000..727b395ddd
--- /dev/null
+++ b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/_next-steps.md
@@ -0,0 +1,8 @@
+---
+# ================================================================================
+# FIXED, DO NOT MODIFY THIS FILE
+# ================================================================================
+weight: 21 # The weight controls the order of the pages. _index.md always has weight 1.
+title: "Next Steps" # Always the same, html page title.
+layout: "learningpathall" # All files under learning paths have this same wrapper for Hugo processing.
+---
diff --git a/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/screenshot.jpg b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/screenshot.jpg
new file mode 100644
index 0000000000..fae38ad7f8
Binary files /dev/null and b/content/learning-paths/mobile-graphics-and-gaming/ai-plank-tutor/screenshot.jpg differ