Skip to content

API Layer

The FRB API layer in crates/api is deliberately thin. It exposes three operations per modality — open, dispatch, close — and delegates everything else to Session. No routing, child loading, or persistence logic lives in the API layer.

session_api.rs maintains a global registry of open sessions:

// Simplified -- actual implementation uses a typed map
static SESSIONS: Lazy<Mutex<HashMap<String, Arc<Mutex<Session<M, I>>>>>> = /* ... */;

Each open session is held behind Arc<Mutex<Session<M, I>>>. The session ID (a string) is the key used by Dart to reference a session.

Each modality has a typed open function that loads or creates a session, subscribes a sink, and returns the session ID:

pub fn open_whiteboard_session(
id: String,
sink: StreamSink<WhiteboardSnapshot>,
) -> String {
// 1. Load from backend (or create new)
// 2. Create Session<Whiteboard, SessionIntent<WhiteboardIntent>>
// 3. Subscribe sink for WhiteboardSnapshot emissions
// 4. Register in session registry
// 5. Return session ID
}
pub fn open_worksheet_session(
id: String,
sink: StreamSink<WorksheetSnapshot>,
) -> String { /* ... */ }
pub fn open_lesson_plan_session(
id: String,
sink: StreamSink<LessonPlanSnapshot>,
) -> String { /* ... */ }

The StreamSink is FRB’s mechanism for pushing typed snapshots from Rust to Dart. After every dispatch() lifecycle (reduce —> recompile —> commit —> emit), the session pushes its M::Snapshot to all subscribed sinks.

Each modality has a typed dispatch function:

pub fn dispatch_whiteboard(
session_id: String,
intent: WhiteboardIntent,
) {
let session = get_session(&session_id);
let mut session = session.lock();
session.dispatch(Command::Intent(SessionIntent::Modality(intent)));
}

For parent modalities, dispatch accepts the parent intent type:

pub fn dispatch_lesson_plan(
session_id: String,
intent: ParentSessionIntent<LessonPlanIntent, SessionIntent<WhiteboardIntent>>,
) {
let session = get_session(&session_id);
let mut session = session.lock();
session.dispatch(Command::Intent(intent));
}

All routing, child loading, and lifecycle logic happens inside Session::dispatch() and the ReduceIntent impls. The API layer just forwards.

pub fn close_session(session_id: String) {
// 1. Remove from registry
// 2. Persist final state (Doc::export_snapshot)
// 3. Drop session (cleans up channels, sinks)
}

Close always persists the final state before dropping the session.

A background loop periodically flushes dirty sessions:

pub fn start_autosave_loop(interval_secs: u64) {
// Spawns a thread that every `interval_secs`:
// 1. Iterates all registered sessions
// 2. Exports updates for sessions with changes since last save
// 3. Writes to backend
}

Typical interval is 30 seconds. close_session always persists final state regardless of the auto-save timer.

Undo and redo are session-level operations exposed through the API:

pub fn session_undo(session_id: String) { /* ... */ }
pub fn session_redo(session_id: String) { /* ... */ }

These operate on the Doc<M::Synced> inside the session, reverting or replaying Loro CRDT operations.

AI initialization and message dispatch are separate API functions:

pub fn init_ai(api_key: String, base_url: String, model: String) {
// Configure the AI runtime (rig model, API credentials)
}
pub fn dispatch_ai(session_id: String, intent: AiIntent) {
let session = get_session(&session_id);
let mut session = session.lock();
session.dispatch(Command::Intent(SessionIntent::Ai(intent)));
}

AiIntent variants include SendMessage { text } and CancelGeneration. The AI agent thread is spawned per-session in session_api.rs::spawn_whiteboard_agent():

  • Session holds self_ref: Option<Weak<Mutex<Self>>> and agent_tx: Option<Sender<String>>
  • The agent thread loops on an mpsc::Receiver<String>, calling runner::chat_stream() per message
  • Feedback flows via CommandSender — periodic Flush dispatches drain events and emit snapshots
  • Tools are created via bridge::create_tools::<M>(session_arc), dispatching through the same Session::dispatch path as Dart
FunctionModalityDirection
open_*_session(id, sink)Per-modalityRust —> Dart (stream)
dispatch_*(session_id, intent)Per-modalityDart —> Rust
close_session(session_id)GenericDart —> Rust
session_undo(session_id)GenericDart —> Rust
session_redo(session_id)GenericDart —> Rust
init_ai(key, url, model)GlobalDart —> Rust
dispatch_ai(session_id, intent)GenericDart —> Rust
start_autosave_loop(interval)GlobalDart —> Rust
  • FRB Patterns — annotation decisions for types crossing the FFI boundary
  • FRB Codegen — regenerating bindings after API changes
  • Whiteboard — leaf modality session example
  • Lesson Plan — parent modality session with child dispatch