Skip to content

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.

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 the Deck modality.
  • Deck holds children as HashMap<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.

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
>,
>,
}

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.

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 type
type 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,
},
}

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
}

When the parent session opens, reconcile_children() creates Slot::Cold(vec![]) for each placement. No child data is loaded yet.

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));
}

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).

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),
}
));

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.

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.

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>,
>,
>;
AspectLessonPlanDeck (this guide)
Child ModalityWhiteboardFlashcard
Child OutputVec<WhiteboardElement>Vec<FlashcardElement>
PositionSlidePositionDeckPosition
ChildSessionIntentSessionIntent<WhiteboardIntent>SessionIntent<FlashcardIntent>
Layout filterskips disabled slidesskips disabled cards

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)
}
}
}

Building a parent-child modality requires:

  1. Parent struct holds children: HashMap<String, Slot<Session<Child, CI>, ChildOutput>>.
  2. HasChildren impl provides children(), children_mut(), selected_child_id(), reconcile_children().
  3. ParentSessionIntent<I, CI> replaces SessionIntent<I> for dispatch routing.
  4. Hot/Cold lifecycle — children start Cold, warm on navigation, Hot sessions run independently.
  5. compile_child delegates to Slot::compile() — Hot recompiles, Cold returns cached.
  6. ParentSessionFeedback handles ChildWarmed to 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.