Parent-Child Modalities
Some modalities contain other modalities. A LessonPlan contains Whiteboard slides. A future Course might contain LessonPlan children. This parent-child relationship is built on three mechanisms: the HasChildren trait, the Slot<C, O> container, and ParentSessionIntent for dispatch routing.
This guide walks through building a Deck modality that contains Flashcard children — a simplified version of how LessonPlan contains Whiteboard slides.
Architecture
Section titled “Architecture”flowchart TD subgraph "Session<Deck, ParentSessionIntent>" D[Deck Modality] DOC[Doc<DeckSynced>] CACHE[CompileCache]
subgraph "Children HashMap" S1["Slot::Hot(Session<Flashcard>)"] S2["Slot::Cold(Vec<FlashcardElement>)"] S3["Slot::Cold(Vec<FlashcardElement>)"] end end
D --> S1 D --> S2 D --> S3- The parent
Session<Deck, ParentSessionIntent<...>>owns theDeckmodality. Deckholds children asHashMap<String, Slot<Session<Flashcard, ...>, Vec<FlashcardElement>>>.- Hot slots are full child sessions with their own
Doc, cache, and reduce cycle. - Cold slots hold cached compiled output only — zero cost, no recompilation.
Step 1: Define the Parent State
Section titled “Step 1: Define the Parent State”The parent modality’s state includes placements that reference children, plus a children map:
#[derive(Clone, PartialEq, Synced, HasResourceMeta)]pub struct DeckSynced { pub placements: Vec<ComponentPlacement<DeckPosition>>, pub title: String, pub resource_meta: ResourceMeta,}
#[derive(Clone, PartialEq, Hash)]pub struct DeckPosition { pub index: usize, pub enabled: bool,}
#[derive(Default)]pub struct DeckEphemeral { pub selected_card_id: Option<String>, pub output: Vec<CompiledCard>,}The Deck struct holds the children map alongside the state:
pub struct Deck { state: State<DeckSynced, DeckEphemeral>, services: Arc<ModalityServices>, cmd_tx: CommandSender<Command<DeckIntent, ()>>, cmd_rx: Mutex<Receiver<Command<DeckIntent, ()>>>, children: HashMap< String, Slot< Session<Flashcard, SessionIntent<FlashcardIntent>>, Vec<FlashcardElement>, // the child's Output type >, >,}Step 2: Implement HasChildren
Section titled “Step 2: Implement HasChildren”HasChildren tells the session how to access and manage child slots:
use modality_core::HasChildren;use modality_session::{Session, SessionIntent, Slot};
impl HasChildren for Deck { type ChildModality = Flashcard; type ChildSessionIntent = SessionIntent<FlashcardIntent>;
fn children(&self) -> &HashMap< String, Slot< Session<Flashcard, SessionIntent<FlashcardIntent>>, Vec<FlashcardElement>, >, > { &self.children }
fn children_mut(&mut self) -> &mut HashMap< String, Slot< Session<Flashcard, SessionIntent<FlashcardIntent>>, Vec<FlashcardElement>, >, > { &mut self.children }
fn selected_child_id(&self) -> Option<&str> { self.state.ephemeral.selected_card_id.as_deref() }
fn reconcile_children(&mut self) { // Create Cold slots for new placements for placement in &self.state.synced.placements { let child_id = placement.component_id .as_ref() .unwrap_or(&placement.id); if !self.children.contains_key(child_id) { self.children.insert( child_id.clone(), Slot::Cold(vec![]), // empty cached output ); } }
// Remove stale slots (placements that no longer exist) let active_ids: HashSet<String> = self.state.synced.placements.iter() .map(|p| p.component_id.clone().unwrap_or_else(|| p.id.clone())) .collect(); self.children.retain(|id, _| active_ids.contains(id)); }}reconcile_children() is called by the ReduceIntent impl after the modality’s reduce runs, before the session lifecycle. It ensures the children map stays in sync with placements.
Step 3: Wire ParentSessionIntent
Section titled “Step 3: Wire ParentSessionIntent”Parent modalities use ParentSessionIntent instead of SessionIntent. This adds Child and WarmChild variants for routing to children:
use modality_session::{ParentSessionIntent, ParentSessionFeedback, SessionIntent};
// The full session typetype DeckSession = Session< Deck, ParentSessionIntent<DeckIntent, SessionIntent<FlashcardIntent>>,>;ParentSessionIntent is an enum with three variant groups:
pub enum ParentSessionIntent<I, CI> { /// Delegate to leaf behavior (Modality, Ai, Export) Base(SessionIntent<I>),
/// Route intent to a child session Child { id: Option<String>, // None = use selected_child_id() intent: CI, },
/// Promote Cold -> Hot by loading from backend WarmChild { id: String, },}Step 4: The Hot/Cold Lifecycle
Section titled “Step 4: The Hot/Cold Lifecycle”All children start Cold. Here is the full lifecycle:
stateDiagram-v2 [*] --> Cold: Session opens Cold --> Warming: WarmChild dispatched Warming --> Hot: ChildWarmed feedback Hot --> Hot: Child dispatches Hot --> [*]: Session closes (persist)
state Cold { [*]: cached output only } state Hot { [*]: full Session with Doc, cache, reduce }Opening — children start Cold
Section titled “Opening — children start Cold”When the parent session opens, reconcile_children() creates Slot::Cold(vec![]) for each placement. No child data is loaded yet.
Warming — promote Cold to Hot
Section titled “Warming — promote Cold to Hot”When the user navigates to a child (e.g., selects a card), Dart dispatches WarmChild:
// From Dart (via FRB):session.dispatch(Command::Intent( ParentSessionIntent::WarmChild { id: "card-1".to_string() }));Inside the ReduceIntent impl, this spawns an async backend load:
// Inside ReduceIntent::reduce for ParentSessionIntent:ParentSessionIntent::WarmChild { id } => { fx.spawn(move |svc, sender| { let bytes = svc.backend.load(&id).unwrap_or_default(); sender.send(Command::Feedback( ParentSessionFeedback::ChildWarmed { id, bytes } )); });}When the feedback arrives on the next dispatch(), the handle_feedback impl creates the Hot session:
ParentSessionFeedback::ChildWarmed { id, bytes } => { // Create a child Session from the loaded bytes let doc = Doc::<FlashcardSynced>::from_bytes(&bytes); let synced = doc.synced(); let child_state = State { synced, ephemeral: FlashcardEphemeral::default(), }; let (agent_service, agent_rx) = AgentService::new(); let services = Arc::new(ModalityServices::new(agent_service)); let child_session = Session::new( child_state, services, state.session_services.clone(), vec![], // child sinks agent_rx, ); state.modality.children_mut().insert(id, Slot::Hot(child_session));}Dispatching to a Hot child
Section titled “Dispatching to a Hot child”Once Hot, intents route directly to the child session:
// From Dart:session.dispatch(Command::Intent( ParentSessionIntent::Child { id: Some("card-1".to_string()), intent: SessionIntent::Modality(FlashcardIntent::SetText { placement_id: "front-text".to_string(), text: "What is 2+2?".to_string(), }), }));The ReduceIntent impl resolves the child and dispatches:
ParentSessionIntent::Child { id, intent } => { let child_id = id.unwrap_or_else(|| state.modality.selected_child_id().unwrap().to_string() ); match state.modality.children_mut().get_mut(&child_id) { Some(Slot::Hot(child_session)) => { // Child does its own full reduce cycle child_session.dispatch(Command::Intent(intent)); // Parent invalidates cache for this placement } Some(Slot::Cold(_)) => { // Error: must warm first fx.services().agent.tool_error( format!("Child '{}' is cold -- dispatch WarmChild first", child_id) ); } None => { fx.services().agent.tool_error( format!("Unknown child '{}'", child_id) ); } }}The child session runs its own independent reduce cycle: drain, reduce, send loop, recompile, commit, emit. After the child finishes, the parent invalidates its cache for that placement and runs its own lifecycle (recompile, emit).
Dispatching to the selected child
Section titled “Dispatching to the selected child”Pass id: None to route to selected_child_id():
session.dispatch(Command::Intent( ParentSessionIntent::Child { id: None, // routes to selected_child_id() intent: SessionIntent::Modality(FlashcardIntent::Flip), }));Step 5: Compilation with Slots
Section titled “Step 5: Compilation with Slots”The parent modality’s compile_child delegates to Slot::compile(). Hot slots recompile; Cold slots return cached output:
impl Modality for Deck { type ChildComponent = Slot< Session<Flashcard, SessionIntent<FlashcardIntent>>, Vec<FlashcardElement>, >;
// ...
fn compile_child( &self, placement: &ComponentPlacement<DeckPosition>, meta: &<Self::ChildComponent as Component>::Metadata, values: &HashMap<String, PropertyValue>, ) -> Result<Vec<FlashcardElement>, CompileError> { let child_id = placement.component_id .as_ref() .unwrap_or(&placement.id); match self.children.get(child_id) { Some(slot) => slot.compile(meta, values), None => Ok(vec![]), // no child yet } }}Slot implements Component:
impl<C: Component, O: Clone> Component for Slot<C, O>where C: Component<Output = O>,{ type Metadata = C::Metadata; type Output = O;
fn compile(&self, meta: &Self::Metadata, values: &HashMap<String, PropertyValue>) -> Result<O, CompileError> { match self { Slot::Hot(component) => component.compile(meta, values), Slot::Cold(cached) => Ok(cached.clone()), } }
// ...}Hot slots delegate to the child session’s compile(), which returns its already-cached output. Cold slots return the cached output directly.
Step 6: Layout with Filtering
Section titled “Step 6: Layout with Filtering”Override layout() if your parent needs to filter children. For example, LessonPlan filters disabled slides:
fn layout<F>( &self, compile_child: F, values: &HashMap<String, PropertyValue>,) -> Result<Vec<(ComponentPlacement<DeckPosition>, Vec<FlashcardElement>)>, CompileError>where F: Fn( &ComponentPlacement<DeckPosition>, &FlashcardMetadata, &HashMap<String, PropertyValue>, ) -> Result<Vec<FlashcardElement>, CompileError>,{ self.placements().iter() .filter(|p| p.position.enabled) // skip disabled cards .map(|p| { let meta = self.child_metadata(p); let resolved = resolve_bindings(&p.bindings, values); compile_child(p, &meta, &resolved).map(|out| (p.clone(), out)) }) .collect()}The default layout() iterates all placements without filtering. Only override when you need custom behavior.
Real-World Example: LessonPlan
Section titled “Real-World Example: LessonPlan”Here is how LessonPlan is actually wired:
pub struct LessonPlan { state: State<LessonPlanSynced, LessonPlanEphemeral>, services: Arc<ModalityServices>, cmd_tx: CommandSender<...>, cmd_rx: Mutex<Receiver<...>>, children: HashMap< String, Slot< Session<Whiteboard, SessionIntent<WhiteboardIntent>>, Vec<WhiteboardElement>, >, >,}
impl HasChildren for LessonPlan { type ChildModality = Whiteboard; type ChildSessionIntent = SessionIntent<WhiteboardIntent>; // ...}
// Session type:type LessonPlanSession = Session< LessonPlan, ParentSessionIntent< LessonPlanIntent, SessionIntent<WhiteboardIntent>, >,>;| Aspect | LessonPlan | Deck (this guide) |
|---|---|---|
| Child Modality | Whiteboard | Flashcard |
| Child Output | Vec<WhiteboardElement> | Vec<FlashcardElement> |
| Position | SlidePosition | DeckPosition |
| ChildSessionIntent | SessionIntent<WhiteboardIntent> | SessionIntent<FlashcardIntent> |
| Layout filter | skips disabled slides | skips disabled cards |
Feedback Types
Section titled “Feedback Types”Parent sessions use ParentSessionFeedback instead of SessionFeedback:
pub enum ParentSessionFeedback { /// Delegate to leaf feedback handling Base(SessionFeedback), /// Child has been loaded and warmed ChildWarmed { id: String, bytes: Vec<u8> },}The ReduceIntent impl handles both:
fn handle_feedback(fb: ParentSessionFeedback, state: &mut DeckSession, fx: &mut ...) { match fb { ParentSessionFeedback::Base(SessionFeedback::ExportComplete(Ok(bytes))) => { state.ephemeral.export.bytes = Some(bytes); state.ephemeral.export.ready = true; } ParentSessionFeedback::Base(SessionFeedback::ExportComplete(Err(e))) => { state.ephemeral.export.error = Some(e); } ParentSessionFeedback::ChildWarmed { id, bytes } => { // Create Hot session from bytes (see Step 4) } }}Summary
Section titled “Summary”Building a parent-child modality requires:
- Parent struct holds
children: HashMap<String, Slot<Session<Child, CI>, ChildOutput>>. HasChildrenimpl provideschildren(),children_mut(),selected_child_id(),reconcile_children().ParentSessionIntent<I, CI>replacesSessionIntent<I>for dispatch routing.- Hot/Cold lifecycle — children start Cold, warm on navigation, Hot sessions run independently.
compile_childdelegates toSlot::compile()— Hot recompiles, Cold returns cached.ParentSessionFeedbackhandlesChildWarmedto create Hot sessions from loaded bytes.
All child lifecycle logic lives inside ReduceIntent impls. The FRB API layer just calls session.dispatch(cmd) — it does not know about Hot, Cold, or children.
Next Steps
Section titled “Next Steps”- Your First Modality — build a leaf modality first
- Wiring AI — AI generation across parent/child boundaries
- Session Reference — full session and dispatch lifecycle