flowchart LR A[Choose app] --> B[Guided or practice] B --> C[Start lesson] C --> D[Present step] D --> E[Collect reply] E --> F[Handle reply] F -->|complete| G[Finish and log] F -->|not complete| H[Clear reply] H --> D
Build a text and voice learning companion with Rasa Pro (CALM)
2026-10-25
AI 380: Human-AI Interaction · Willamette University
A conversational coach that teaches older adults to use a new app, one step at a time, by text or voice.
You write every project file yourself.
Verification boundary: The supplied tutorial reports testing its Python actions with the Rasa SDK. Rasa Pro training, YAML execution, Inspector behavior, and voice were not tested in that build. The checkpoints catch integration problems early.
Part 1 · Problem, scope, and conversation design
From “help me use an app” to a clear, testable interaction.
Present → wait → handle → repeat
| Item | Vague version | Explicit version |
|---|---|---|
| Target problem | Difficulty learning new apps | An older adult wants to learn one app task, such as a video call or medication reminder, but gets lost in unfamiliar screens. |
| User need | A coach that answers questions | Give one instruction at a time, wait for “Done,” and support repeat, help, and progress requests. |
| AI artefact | Dialogue, overlay, confirmation, logging, practice | Text + voice, button choices, a guided loop, practice mode, a step log, and a highlight message for a future front end. |
video_call and medication_reminder.{"highlight": "add_reminder_button"}; a front end must draw the highlight.FamilyCall and PillTime are fictional demo apps. Replace their lesson text with verified instructions for any real app. Do not treat this tutorial as real app instructions or medical advice.
COACH: Which app would you like to learn? Video calls, or medication reminders? [Buttons]
USER: Medication reminder. [Tap or speech]
COACH: Do you want me to walk you through it, or would you like to practice? [Buttons]
USER: Walk me through it.
COACH: Great. Let’s learn PillTime in guided mode. There are 4 steps. After each step, say “done.”
COACH: Step 1 of 4. Open the PillTime app by tapping the orange pill icon. [highlight: app_icon]
USER: Done.
COACH: Well done! Step 2 of 4. Tap the large green “Add Reminder” button. [highlight: add_reminder_button]
USER: Done.
Continue through steps 3 and 4…
COACH: Congratulations! You finished the PillTime lesson.
| User says | Coach does | Logic lives in |
|---|---|---|
| “Done” | Advances or finishes | action_handle_reply |
| “Again” / “repeat” | Repeats the same step | action_handle_reply |
| “I’m stuck” | Repeats the step plus a tip | action_handle_reply |
| “Where am I?” | Reports progress; returns to the same step | Separate side flow |
| “What’s the weather?” | Brief small-talk response, then return | Template chitchat pattern |
Full instruction and a highlight message.
“Open PillTime by tapping the orange pill icon.”
Only the goal; no highlight.
“Your turn! Your goal: open PillTime.”
If the user says “stuck,” reveal the full instruction and a tip.
Fading support: Help is available, but not forced.
Append one JSON line for each important event:
A caregiver or researcher could review the record later, with appropriate access and consent.
flowchart LR A[Choose app] --> B[Guided or practice] B --> C[Start lesson] C --> D[Present step] D --> E[Collect reply] E --> F[Handle reply] F -->|complete| G[Finish and log] F -->|not complete| H[Clear reply] H --> D
Start: set step 0 and log. Present: narrate, send highlight, log.
Handle: log the reply and update state. Repeat: until complete.
| Concept | Name | Job |
|---|---|---|
| Flow | learn_app |
Main lesson loop |
| Flow | where_am_i |
Side question without breaking the lesson |
| Categorical slot | app_choice |
video_call or medication_reminder |
| Boolean slot | practice_mode |
true: practice; false: guided |
| Categorical slot | step_reply |
done, repeat, or stuck |
| Concept | Name | Job |
|---|---|---|
| Float slot | step_index |
Current step; zero-based |
| Float slot | total_steps |
Number of steps |
| Boolean slot | extra_help |
Show a tip on the next presentation? |
| Boolean slot | lesson_complete |
Finish or loop? |
| Response | utter_ask_app_choice |
Ask which lesson |
| Response | utter_ask_practice_mode |
Ask guided or practice |
| Response | utter_ask_step_reply |
Ask done, repeat, or stuck |
| Action | Job |
|---|---|
action_start_lesson |
Initialize the lesson and log the start |
action_present_step |
Narrate and send the highlight |
action_handle_reply |
Interpret done / repeat / stuck |
action_finish_lesson |
Congratulate and log completion |
action_where_am_i |
Report progress |
Flow: what happens next. Slot: what is remembered. Response: fixed words. Action: computation that returns state changes.
Part 2 · Write, inspect, and test
Slots → responses → flows → actions
Keep conversation order in flows and computation in Python.
10 minutes
The template starts as a money-transfer demo. You will replace its content.
Checkpoint 0: Run rasa train on the untouched template. Fix installation or license issues before changing anything.
App_Coach/
├── actions/
│ ├── __init__.py KEEP (empty)
│ ├── actions.py REPLACE (Step 5)
│ └── lessons.py NEW (Step 5)
├── data/
│ ├── flows.yml REPLACE (Steps 3 and 4)
│ └── patterns.yml KEEP (small talk)
├── domain.yml REPLACE (Steps 1 and 2)
├── config.yml KEEP
├── endpoints.yml KEEP (embedded actions)
└── credentials.yml KEEP (REST and browser_audio)10 minutes · Start replacing domain.yml
from_llm: filled from the user’s words by the language model.controlled: programmatically set, rather than inferred from chat; the tutorial also uses explicit button slot assignments.Type choices
int when using an index in Python.domain.yml · Begin the file with this block
Continue inside slots: at the same indentation
Continue inside slots:
Continue inside slots:
Discuss: Why are step_index and lesson_complete controlled?
So saying “step 9” or “I’m finished” does not directly assign internal progress; the code updates it based on the reply.
Checkpoint 1: Keep the file open. Do not train yet.
15 minutes · Continue domain.yml
A collection step asks using utter_ask_<slot_name>.
Buttons contain a visible title and a payload:
list.Voice rule: Ask the full question in words. Do not rely on “Tap a button below.”
Add a top-level responses: block
responses:
utter_ask_app_choice:
- text: "Which app would you like to learn? Video calls, or medication reminders?"
buttons:
- title: "Video calls"
payload: "/SetSlots(app_choice=video_call)"
- title: "Medication reminder"
payload: "/SetSlots(app_choice=medication_reminder)"One question, two clear choices, and wording that works in speech.
Continue inside responses:
utter_ask_practice_mode:
- text: "Do you want me to walk you through it, or would you like to practice on your own?"
buttons:
- title: "Walk me through it"
payload: "/SetSlots(practice_mode=false)"
- title: "Let me practice"
payload: "/SetSlots(practice_mode=true)"Button: assigns the slot directly. Voice: the LLM interprets the user’s words.
Continue inside responses:
utter_ask_step_reply:
- text: "Say 'done' when you have finished this step. Say 'again' to hear it once more, or 'stuck' if you need help."
buttons:
- title: "Done"
payload: "/SetSlots(step_reply=done)"
- title: "Say it again"
payload: "/SetSlots(step_reply=repeat)"
- title: "I'm stuck"
payload: "/SetSlots(step_reply=stuck)"Short titles; one decision per question; three buttons at most.
repeat value.Declare actions under a new top-level key in domain.yml:
Inside responses: · Indent like the other utter_... entries
utter_free_chitchat_response:
- text: "placeholder"
metadata:
rephrase: True
rephrase_prompt: |
The following is a conversation with a friendly AI assistant called App Coach.
The assistant helps older adults learn to use new apps, one small step at a time.
The assistant is warm, patient, and speaks in short, clear sentences.
The user is making small talk. Respond briefly and kindly, then invite the user to continue the lesson.
Context / previous conversation with the user:
{{history}}
{{current_input}}
Suggested AI Response:Organize the four top-level keys as:
version · slots · actions · responses
There should be four utter_... entries inside the single responses: block.
This checks YAML structure, not Rasa semantic validity.
15 minutes · Replace data/flows.yml
| Step | Meaning |
|---|---|
collect: some_slot |
Request the value using utter_ask_some_slot; filled values may skip collection |
action: name |
Run a response or custom action |
set_slots |
Assign values; use null to clear |
next: |
Choose the next step; branch with if / then / else |
id: |
Label a step so another step can jump to it |
Flow description: Tell the LLM what the flow does for the user.
data/flows.yml · First half of learn_app
flows:
learn_app:
name: learn an app
description: >
Coach the user step by step through learning how to use a new app,
such as making video calls or setting a medication reminder.
Use when the user wants to learn, set up, install, practice, or get
help using an app.
steps:
- collect: app_choice
description: "The app the user wants to learn: video_call or medication_reminder."
- collect: practice_mode
description: >
true if the user wants to practice on their own with less help;
false if the user wants to be walked through step by step.
- action: action_start_lessoncollect: app_choice uses utter_ask_app_choice, including its buttons.app_choice, so the app question can be skipped.collect guides interpretation of that slot.action_start_lesson is the custom action you will write in Step 5.Checkpoint 3: The action does not exist yet. Continue building before testing.
20 minutes · Append below action_start_lesson in learn_app
- id: present
action: action_present_step
- id: ask_reply
collect: step_reply
ask_before_filling: true
description: >
What the user says about the current step: done if they finished it,
repeat if they want to hear it again, stuck if they need help.
- action: action_handle_reply
next:
- if: slots.lesson_complete
then: wrap_up
- else: reset_replyContinue at the same step-list indentation
- id: reset_reply
set_slots:
- step_reply: null
next: present
- id: wrap_up
action: action_finish_lessonwrap_up; otherwise clear the reply and return to present.step_reply: null prevents the last “done” from becoming a stale reply for a new step.
ask_before_filling: true requests a fresh answer rather than silently relying on a previously filled value. Verify collection behavior in your Rasa version.
Trace it: Start at
step_index = 0. The user says “stuck.” Which action changesextra_help, and when does it return tofalse?
One side flow · Same indentation as learn_app:
where_am_i:
name: where am I
description: >
The user asks which step they are on, how far along they are,
or how many steps are left in the current lesson.
steps:
- action: action_where_am_iCALM can pause learn_app, run this side flow, and resume the lesson at its pending reply collection.
Test that resumption rather than assuming it worked.
Expected: ['learn_app', 'where_am_i'].
Version-sensitive items retained from the source tutorial:
next: present.slots.lesson_complete versus bare lesson_complete.The source cites Rasa’s transfer example for backward jumps but flags the general behavior for verification. Retrain and inspect the loop in your installed version.
30 minutes · Python does the computation
| Operation | Rasa SDK API |
|---|---|
| Read memory | tracker.get_slot("name") |
| Speak | dispatcher.utter_message(text="...") |
| Update memory | Return SlotSet("name", value) events |
| Other computation | Read content, call APIs, write logs |
The template’s endpoints.yml uses action_endpoint: actions_module: "actions", so actions run in-process. No separate action server is needed.
Read slots → decide → speak → log → return SlotSets
Create actions/lessons.py · Add the following pieces in order
"""Lesson content and a tiny logger.
The apps below are FICTIONAL demo apps. Replace the text with the real steps of your own app."""
import json
import time
from pathlib import Path
LESSONS = {
"video_call": {
"title": "FamilyCall (demo app)",
"steps": [Each step has instruction (guided), goal (practice), tip (help), and highlight (front-end element name).
Teachers can edit the content without changing the action logic.
Continue inside the video_call steps list
{"instruction": "Find the blue 'FamilyCall' icon on your home screen and tap it once.",
"goal": "Open the FamilyCall app.",
"tip": "The icon is a blue circle with a white camera. If you cannot see it, swipe left on your home screen.",
"highlight": "app_icon"},
{"instruction": "Tap the large green 'Call' button at the bottom of the screen.",
"goal": "Start a new call.",
"tip": "The button is the biggest one on the screen, near your thumb.",
"highlight": "call_button"},These are fictional screens, not instructions for a real video-call app.
Continue, then close the first lesson
{"instruction": "Tap your daughter's photo in the contact list.",
"goal": "Choose who to call.",
"tip": "Contacts are shown as photos. Scroll down gently if you do not see her.",
"highlight": "contact_photo"},
{"instruction": "Wait for her to answer. When you see her face, say hello! To finish, tap the red 'End' button.",
"goal": "Finish the call.",
"tip": "The red button is at the bottom middle of the screen.",
"highlight": "end_button"},
],
},Continue inside LESSONS
"medication_reminder": {
"title": "PillTime (demo app)",
"steps": [
{"instruction": "Open the 'PillTime' app by tapping the orange pill icon.",
"goal": "Open PillTime.",
"tip": "The icon is orange with a white pill shape.",
"highlight": "app_icon"},
{"instruction": "Tap the large green 'Add Reminder' button.",
"goal": "Start a new reminder.",
"tip": "The button is at the bottom of the screen and has a plus sign.",
"highlight": "add_reminder_button"},Close the steps, lesson, and LESSONS dictionary
{"instruction": "Type the name of your medicine, then tap 'Next'.",
"goal": "Name the medicine.",
"tip": "You can also tap the microphone on the keyboard and say the name.",
"highlight": "medicine_name_field"},
{"instruction": "Pick the time of day you take it, then tap the green 'Save' button.",
"goal": "Set the time and save.",
"tip": "Tap the clock face, move the hands, and press 'OK'. Then press Save.",
"highlight": "save_button"},
],
},
}Append to actions/lessons.py after LESSONS
LOG_FILE = Path("lesson_log.jsonl")
def log_event(sender_id, app, step, event):
"""Append one JSON line per event. Open the file later to review what happened."""
row = {"time": time.strftime("%Y-%m-%d %H:%M:%S"), "user": sender_id,
"app": app, "step": step, "event": event}
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(json.dumps(row) + "\n")The file is created in the directory where the assistant is launched.
Replace actions/actions.py · Piece 1
from typing import Any, Dict, List, Text
from rasa_sdk import Action, Tracker
from rasa_sdk.events import SlotSet
from rasa_sdk.executor import CollectingDispatcher
from .lessons import LESSONS, log_event
def _step(tracker: Tracker):
"""Return (app, index, step_dict) for the current lesson step."""
app = tracker.get_slot("app_choice")
index = int(tracker.get_slot("step_index") or 0)
return app, index, LESSONS[app]["steps"][index]The dot in .lessons means “the module next to this one” in the actions package.
actions/actions.py · Piece 2, first class
class ActionStartLesson(Action):
def name(self) -> Text:
return "action_start_lesson"
def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict[Text, Any]) -> List[Dict[Text, Any]]:
app = tracker.get_slot("app_choice")
if app not in LESSONS:
dispatcher.utter_message(text="Sorry, I do not have a lesson for that app yet.")
return []
total = len(LESSONS[app]["steps"])
log_event(tracker.sender_id, app, 0, "lesson_started")
mode = "practice mode" if tracker.get_slot("practice_mode") else "guided mode"Continue inside ActionStartLesson.run
dispatcher.utter_message(
text=f"Great. Let's learn {LESSONS[app]['title']} in {mode}. There are {total} steps. "
"After each step, say 'done'. Say 'again' to hear it once more, or 'stuck' if you need help.")
return [SlotSet("step_index", 0.0), SlotSet("total_steps", float(total)),
SlotSet("extra_help", False), SlotSet("lesson_complete", False)]Reset at the start: Custom-action state can persist. A new lesson must not inherit the old lesson’s index, help flag, or completion flag.
Add the second class at top level
class ActionPresentStep(Action):
def name(self) -> Text:
return "action_present_step"
def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict[Text, Any]) -> List[Dict[Text, Any]]:
app, index, step = _step(tracker)
total = len(LESSONS[app]["steps"])
practice = bool(tracker.get_slot("practice_mode"))
stuck = bool(tracker.get_slot("extra_help"))The same content supports two different levels of assistance.
Continue inside ActionPresentStep.run
if practice and not stuck:
text = f"Step {index + 1} of {total}. Your turn! Your goal: {step['goal']}"
payload = {}
else:
text = f"Step {index + 1} of {total}. {step['instruction']}"
if stuck:
text += f" Here is a tip: {step['tip']}"
payload = {"highlight": step["highlight"], "step": index + 1}
dispatcher.utter_message(text=text, json_message=payload or None)
log_event(tracker.sender_id, app, index + 1, "step_shown_help" if stuck else "step_shown")
return [SlotSet("extra_help", False)]Practice gives the goal without a highlight. Help reveals the instruction and tip once.
json_message=payload attaches structured data to the response:
custom payload alongside the text.highlight and draw a ring around that element.Piece 3 · Add ActionHandleReply at top level
class ActionHandleReply(Action):
def name(self) -> Text:
return "action_handle_reply"
def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict[Text, Any]) -> List[Dict[Text, Any]]:
app, index, _ = _step(tracker)
total = len(LESSONS[app]["steps"])
reply = tracker.get_slot("step_reply")
log_event(tracker.sender_id, app, index + 1, f"reply_{reply}")The action computes state changes; the flow decides which conversation step follows.
Continue inside ActionHandleReply.run
if reply == "done":
if index + 1 >= total:
return [SlotSet("lesson_complete", True)]
dispatcher.utter_message(text="Well done!")
return [SlotSet("step_index", float(index + 1)), SlotSet("lesson_complete", False)]
if reply == "stuck":
return [SlotSet("extra_help", True), SlotSet("lesson_complete", False)]
return [SlotSet("lesson_complete", False)]Done: advance or finish. Stuck: keep the index and request help. Repeat: keep the same step unchanged.
Add ActionFinishLesson at top level
class ActionFinishLesson(Action):
def name(self) -> Text:
return "action_finish_lesson"
def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict[Text, Any]) -> List[Dict[Text, Any]]:
app = tracker.get_slot("app_choice")
log_event(tracker.sender_id, app, int(tracker.get_slot("total_steps") or 0), "lesson_completed")
extra = "" if tracker.get_slot("practice_mode") else " Want to try again without my help? Just say 'practice'."
dispatcher.utter_message(text=f"Congratulations! You finished the {LESSONS[app]['title']} lesson.{extra}")
return []Add ActionWhereAmI at top level
class ActionWhereAmI(Action):
def name(self) -> Text:
return "action_where_am_i"
def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict[Text, Any]) -> List[Dict[Text, Any]]:
app = tracker.get_slot("app_choice")
if app not in LESSONS:
dispatcher.utter_message(text="We have not started a lesson yet.")
return []
index = int(tracker.get_slot("step_index") or 0)
dispatcher.utter_message(text=f"You are on step {index + 1} of {len(LESSONS[app]['steps'])} of the {LESSONS[app]['title']} lesson.")
return []Parse both Python files without importing or running them:
python -c "import ast,sys; [ast.parse(open(f).read()) for f in ('actions/actions.py','actions/lessons.py')]; print('syntax OK')"Trace one round on paper:
step_index = 0 → user says stuck → extra_help = True → clear reply → present instruction + tip → extra_help = False.
The action changes slots. The flow reads those slots to choose the next conversation step.
Create try_actions.py in the project root · Part 1
from rasa_sdk import Tracker
from rasa_sdk.executor import CollectingDispatcher
from actions import actions as A
slots = {"app_choice": "medication_reminder", "practice_mode": False, "step_index": 0.0,
"step_reply": None, "extra_help": False, "total_steps": None, "lesson_complete": False}
def tracker():
return Tracker("me", dict(slots), {}, [], False, None, {}, "action_listen")
def run(action):
d = CollectingDispatcher()
for e in action.run(d, tracker(), {}):
slots[e["name"]] = e["value"]
for m in d.messages:
print("BOT:", m["text"], "| overlay:", m["custom"])Append to try_actions.py · Part 2
run(A.ActionStartLesson())
run(A.ActionPresentStep())
slots["step_reply"] = "stuck"; run(A.ActionHandleReply()); run(A.ActionPresentStep())
slots["step_reply"] = "done"; run(A.ActionHandleReply()); run(A.ActionPresentStep())Run python try_actions.py.
Expected: Step 1 → Step 1 with a tip → “Well done!” → Step 2.
The run creates lesson_log.jsonl. Delete the scratch file and test log after reviewing them. This harness needs the Rasa SDK, but not a running Rasa Pro assistant.
20 minutes
Click Inspect to open the Flow, History, and Memory panels.
Run the following script and record actual behavior, including differences from the expected result.
| # | Type or tap | Expected |
|---|---|---|
| 1 | “I want to learn an app” | Question with two buttons |
| 2 | Medication reminder | Guided/practice choice |
| 3 | Walk me through it | Welcome, Step 1 of 4, then reply question |
| 4 | I’m stuck | Step 1 repeated with a tip |
| 5 | “where am I?” | “You are on step 1 of 4…” then reply question |
| 6 | Done | “Well done!” then Step 2 |
| # | Type or tap | Expected |
|---|---|---|
| 7 | “again” | Repeat Step 2 |
| 8 | Done three times, one reply per step | Steps 3 and 4, then congratulations |
| 9 | “I want to learn video calls” | App question skipped if slot is filled; mode question next |
| 10 | Let me practice | Goal only, without tip or highlight |
| 11 | “I’m stuck” | Full instruction and tip |
| 12 | “What a nice day” | Brief small talk, then continue |
learn_app becomes Interrupted during where_am_i, then Active again.step_index advances 0, 1, 2…; lesson_complete becomes true at the end; extra_help enables one helped presentation.{"highlight": ..., "step": ...}.Checkpoint 6: Complete a guided lesson, a stuck-and-recover interaction, a progress digression, and a practice run.
5 minutes · Open lesson_log.jsonl where you launched Rasa
{"time": "2026-10-25 23:13:22", "user": "u1", "app": "medication_reminder", "step": 1, "event": "step_shown"}
{"time": "2026-10-25 23:13:22", "user": "u1", "app": "medication_reminder", "step": 1, "event": "reply_stuck"}15 minutes · Test by text before speech
The template’s browser_audio channel uses Deepgram for listening and speaking.
Switch to voice, allow microphone access, and say:
“I want to learn the medication reminder.”
| Issue | Design response |
|---|---|
| Buttons are unavailable | List choices in the spoken question |
| Long coach messages | One idea per message; short sentences |
| User says “Done” during speech | Configure and test channel interruptions |
| “again” or “done” is misheard | Record errors; consider synonyms such as “finished,” “okay,” or “next” in the reply description |
| Highlight payload has no sound | It is overlay data, not speech |
Checkpoint 8: Finish a lesson by voice. Record two misrecognized words, or report none if none occurred.
Optional discussion · Rasa supplies only half the artefact
custom payload.highlight, for example add_reminder_button.Compare tap-to-confirm with spoken “done”: which errors does each prevent, and which can it introduce?
The tutorial describes WebSocket voice custom payloads as separate custom frames. Verify the protocol in the documentation for your installed version.
Rasa’s documentation does not provide the browser overlay demo used here; that front end is yours to build.
Extension: Build a one-page mock app with the elements named in lessons.py and a highlight ring.
This assistant narrates, waits for confirmation, branches, logs, and names UI elements. It does not control or inspect a real phone screen.
Parts 3–5 · Troubleshooting, extensions, and reflection
Make the assistant work, then ask whether it helps.
Correct execution is not the same as useful learning.
| Symptom | Check |
|---|---|
| Unknown action during training | domain.yml action names must exactly match each class’s name() |
| App question never appears | LLM may have filled the slot already; test a vague opener |
| Coach races through steps | Check reset_reply and the indentation of the step_reply: null assignment |
| Loop never ends | Check action_handle_reply sets completion; verify condition syntax |
KeyError in Python |
Ensure app_choice is filled before actions run |
| Symptom | Check |
|---|---|
| Second lesson starts at wrong step | action_start_lesson must reset step_index |
| No spoken replies | Key exported in the launch terminal? Microphone allowed? |
| Chitchat says “placeholder” | Restore template nlg: rephrase in endpoints.yml |
Unrelated messages start learn_app |
Tighten its description and retrain |
lessons.py; the reusable lesson loop should not need to change.back to step_reply and decrement the index without going below zero.stop_lesson, log lesson_abandoned, and say goodbye.done? What does the LLM control, and what is controlled only by code?Consider task success, retention, help requests, correction effort, and the user’s sense of control.
Reported tested by the supplied tutorial: Python actions with real Rasa SDK classes: guided loop, stuck, repeat, practice, logging, and highlight payloads.
Not tested in that source build: Rasa Pro training, domain and flow execution, Inspector behavior, and voice.
Verify locally: Backward jumps, exact condition syntax, custom payload display, and all checkpoints.
Fictional lessons: FamilyCall and PillTime are invented. This is not medical advice; a real medication coach requires health-professional review.
Choose → present → confirm → recover → resume → finish
A useful coach supports the learner’s pace, choices, and recovery.