Skip to main content

Protocol Overview

Flower Engine uses a JSON-based WebSocket protocol (V1) for bidirectional communication between the FastAPI backend and Ratatui TUI.
Connection: ws://localhost:8000/ws/rpcFormat: Text frames containing JSON objects

Message Structure

All messages follow a uniform envelope format:
{
  "event": "event_type",
  "payload": {
    "content": "message content",
    "metadata": {
      // Optional contextual data
    }
  }
}

Type Definitions

From tui/src/models.rs:14-44:
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct WsMessage {
    pub event: String,
    pub payload: Payload,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Payload {
    pub content: String,
    #[serde(default)]
    pub metadata: Metadata,
}

#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct Metadata {
    pub model: Option<String>,
    pub model_confirmed: Option<bool>,
    pub tokens_per_second: Option<f64>,
    pub world_id: Option<String>,
    pub character_id: Option<String>,
    pub status: Option<String>,
    pub total_tokens: Option<u32>,
    pub available_worlds: Option<Vec<EntityInfo>>,
    pub available_characters: Option<Vec<EntityInfo>>,
    pub available_models: Option<Vec<EntityInfo>>,
    pub available_rules: Option<Vec<EntityInfo>>,
    pub active_rules: Option<Vec<String>>,
    pub available_sessions: Option<Vec<EntityInfo>>,
    pub session_id: Option<String>,
    pub history: Option<Vec<HistoryMessage>>,
}
Metadata is always optional. Clients should gracefully handle missing fields using Option<T> (Rust) or dict.get() (Python).

Client → Server Messages

User Input

From tui/src/ws.rs:49-62, the TUI sends user prompts:
let json_msg = serde_json::json!({ "prompt": out_msg });
write.send(Message::Text(json_msg.to_string())).await?;
Example:
{
  "prompt": "I examine the ancient door for traps."
}

Commands

Commands are prompts prefixed with /:
{"prompt": "/world select cyberpunk_city"}
{"prompt": "/character select mercenary"}
{"prompt": "/session new"}
{"prompt": "/model anthropic/claude-3.5-sonnet"}
{"prompt": "/rules add gritty"}
{"prompt": "/cancel"}
From engine/main.py:169-171, commands are routed to a dedicated handler:
if prompt.startswith("/"):
    await handle_command(prompt, websocket)
    continue
Special case: /cancel during LLM streaming triggers asyncio.CancelledError (see engine/main.py:239).

Server → Client Events

system_update

System notifications and confirmations. Example:
{
  "event": "system_update",
  "payload": {
    "content": "✓ Engine ready.",
    "metadata": {
      "status": "ok"
    }
  }
}
From engine/utils.py:7-14, the builder function:
def build_ws_payload(event: str, content: str, metadata: Dict[str, Any] = None) -> str:
    return json.dumps({
        "event": event,
        "payload": {
            "content": content,
            "metadata": metadata or {}
        }
    })
Common Uses:
  • Connection established: "✓ Engine ready."
  • World selected: "✓ World 'cyberpunk_city' activated."
  • Session created: "✓ New session started."
  • Error messages: "✗ No character selected."
From tui/src/main.rs:112-123, the TUI displays these as system messages:
"system_update" => {
    app.add_system_message(msg.payload.content);
    if let Some(model) = msg.payload.metadata.model { 
        app.active_model = model;
    }
}

sync_state

Complete state synchronization, sent after connection and after state-changing commands. Example:
{
  "event": "sync_state",
  "payload": {
    "content": "Synced",
    "metadata": {
      "model": "anthropic/claude-3.5-sonnet",
      "model_confirmed": true,
      "world_id": "cyberpunk_city",
      "character_id": "mercenary",
      "session_id": "550e8400-e29b-41d4-a716-446655440000",
      "available_worlds": [
        {"id": "cyberpunk_city", "name": "Neo-Tokyo 2077"},
        {"id": "fantasy_realm", "name": "Eldoria"}
      ],
      "available_characters": [
        {"id": "mercenary", "name": "Kira Chen"}
      ],
      "available_models": [
        {
          "id": "anthropic/claude-3.5-sonnet",
          "name": "[OpenRouter] Claude 3.5 Sonnet",
          "prompt_price": 3.0,
          "completion_price": 15.0
        }
      ],
      "available_rules": [
        {"id": "gritty", "name": "Gritty Realism"}
      ],
      "active_rules": ["gritty"],
      "available_sessions": [
        {"id": "550e8400...", "name": "I examine the ancient door"}
      ]
    }
  }
}
From tui/src/main.rs:77-102, the TUI updates all UI state:
"sync_state" => {
    app.status = "Synced".to_string();
    if let Some(model) = msg.payload.metadata.model { 
        app.active_model = model.clone();
        // Update pricing info
        if let Some(info) = app.available_models.iter().find(|m| m.id == model) {
            app.active_prompt_price = info.prompt_price;
            app.active_completion_price = info.completion_price;
        }
    }
    if let Some(w_id) = msg.payload.metadata.world_id { 
        app.world_id = w_id; 
    }
    if let Some(worlds) = msg.payload.metadata.available_worlds { 
        app.available_worlds = worlds; 
    }
    // ... update 10+ UI fields
}
When to send: After connection, after /world select, /character select, /model, /rules add, /session new/continue.

chat_history

Session message history, sent when loading a session. Example:
{
  "event": "chat_history",
  "payload": {
    "content": "",
    "metadata": {
      "history": [
        {"role": "user", "content": "I examine the door."},
        {"role": "assistant", "content": "You notice faint scratches..."}
      ]
    }
  }
}
From tui/src/main.rs:104-111:
"chat_history" => {
    if let Some(history) = msg.payload.metadata.history {
        let converted = history.into_iter()
            .map(|m| (m.role, m.content))
            .collect();
        app.load_history(converted);
    }
}

chat_chunk

Real-time LLM token streaming. Example:
{
  "event": "chat_chunk",
  "payload": {
    "content": "You notice ",
    "metadata": {
      "model": "anthropic/claude-3.5-sonnet",
      "tokens_per_second": 42.7
    }
  }
}
From engine/llm.py:206-220, tokens are streamed as they arrive:
async for chunk in response:
    delta = chunk.choices[0].delta.content if chunk.choices else None
    if delta:
        full_content += delta
        total_tokens += 1
        elapsed = time.time() - start_time
        tps = total_tokens / elapsed if elapsed > 0 else 0.0
        
        metadata = {
            "model": state.CURRENT_MODEL,
            "tokens_per_second": round(tps, 2),
        }
        await ws.send_text(build_ws_payload("chat_chunk", delta, metadata))
From tui/src/main.rs:124-135, the TUI appends each chunk immediately:
"chat_chunk" => {
    app.append_chunk(&msg.payload.content);
    if let Some(tps) = msg.payload.metadata.tokens_per_second { 
        app.tps = tps; 
    }
    if let Some(model) = msg.payload.metadata.model { 
        app.active_model = model.clone();
    }
}
Result: Typewriter effect with live tokens/sec counter.

chat_end

Signals completion of LLM response. Example:
{
  "event": "chat_end",
  "payload": {
    "content": "",
    "metadata": {
      "total_tokens": 347
    }
  }
}
From engine/llm.py:229-231:
finally:
    await ws.send_text(
        build_ws_payload("chat_end", "", {"total_tokens": total_tokens})
    )
From tui/src/main.rs:136-140:
"chat_end" => {
    if let Some(total) = msg.payload.metadata.total_tokens { 
        app.total_tokens += total; 
    }
    app.finish_stream();
    app.status = "Idle".to_string();
}
Metadata tracking: total_tokens accumulates across the session for cost estimation.

error

Error notifications (non-fatal). Example:
{
  "event": "error",
  "payload": {
    "content": "Model not found: invalid/model-id",
    "metadata": {}
  }
}
From engine/llm.py:225-227:
except Exception as e:
    log.error(f"Error during streaming: {e}")
    await ws.send_text(build_ws_payload("error", str(e)))
    return
From tui/src/main.rs:141-144:
"error" => {
    app.add_system_message(format!("✗ {}", msg.payload.content));
    app.is_typing = false;
}

Connection Lifecycle

1

TUI Connects

Rust client attempts WebSocket connection to ws://localhost:8000/ws/rpcFrom tui/src/ws.rs:15-22:
let ws_stream = loop {
    match connect_async(url.clone()).await {
        Ok((stream, _)) => break stream,
        Err(_) => {
            tokio::time::sleep(Duration::from_secs(1)).await;
        }
    }
};
2

Backend Accepts

Python FastAPI accepts connection:
@app.websocket("/ws/rpc")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
3

Initial Handshake

Backend sends system_update (”✓ Engine ready.”) then sync_state with full initial state
4

Active Session

  • TUI sends {"prompt": "..."}
  • Backend responds with chat_chunk stream → chat_end
  • Commands trigger system_update + sync_state
5

Disconnection

  • User quits TUI (Esc)
  • WebSocket closes gracefully
  • Backend cleans up resources

Error Handling

Client-Side (Rust)

From tui/src/ws.rs:28-46:
while let Some(msg) = read.next().await {
    match msg {
        Ok(Message::Text(text)) => {
            if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
                tx.send(ws_msg).unwrap();
            } else {
                // Log but don't crash
                eprintln!("Failed to parse WS message: {}", text);
            }
        }
        Ok(Message::Close(_)) => break,
        Err(_) => break,
        _ => {}
    }
}
Graceful degradation: Malformed JSON is logged, not crashed.

Server-Side (Python)

From engine/main.py:161-167:
data = await websocket.receive_text()
try:
    msg = json.loads(data)
    prompt = msg.get("prompt", "")
except:
    prompt = data  # Fallback to raw string
Lenient parsing: If JSON parsing fails, treat the entire message as a prompt string.

Message Examples

Complete Interaction Flow

Backend → TUI:
{"event": "system_update", "payload": {"content": "✓ Engine ready.", "metadata": {"status": "ok"}}}
{"event": "sync_state", "payload": {"content": "Synced", "metadata": {...}}}
TUI → Backend:
{"prompt": "/world select cyberpunk_city"}
Backend → TUI:
{"event": "system_update", "payload": {"content": "✓ World 'cyberpunk_city' activated.", "metadata": {}}}
{"event": "sync_state", "payload": {"content": "Synced", "metadata": {"world_id": "cyberpunk_city", ...}}}
TUI → Backend:
{"prompt": "/session new"}
Backend → TUI:
{"event": "system_update", "payload": {"content": "✓ New session started.", "metadata": {"session_id": "550e8400-e29b..."}}}
{"event": "chat_chunk", "payload": {"content": "The ", "metadata": {}}}
{"event": "chat_chunk", "payload": {"content": "neon ", "metadata": {"tokens_per_second": 38.2}}}
{"event": "chat_chunk", "payload": {"content": "lights ", "metadata": {"tokens_per_second": 41.5}}}
// ... more chunks
{"event": "chat_end", "payload": {"content": "", "metadata": {"total_tokens": 127}}}
TUI → Backend:
{"prompt": "I examine the door for traps."}
Backend → TUI (stream):
{"event": "chat_chunk", "payload": {"content": "You ", "metadata": {"tokens_per_second": 45.1}}}
{"event": "chat_chunk", "payload": {"content": "crouch ", "metadata": {"tokens_per_second": 46.3}}}
// ... full response
{"event": "chat_end", "payload": {"content": "", "metadata": {"total_tokens": 89}}}
TUI → Backend (during stream):
{"prompt": "/cancel"}
Backend → TUI:
{"event": "system_update", "payload": {"content": "✗ Stream cancelled by user.", "metadata": {}}}

Protocol Versioning

The current protocol is V1 (implicit). Future versions may add:

Compression

gzip for large lore chunks

Binary Frames

MessagePack for reduced overhead

Subscriptions

Event filtering (e.g., “only errors”)

Authentication

Token-based auth for remote connections
Breaking changes will increment the protocol version (e.g., ws://localhost:8000/ws/v2/rpc).

Implementation Tips

Python Backend

from engine.utils import build_ws_payload

# System notification
await websocket.send_text(
    build_ws_payload("system_update", "✓ World loaded.", {"world_id": "my_world"})
)

# State sync
await websocket.send_text(
    build_ws_payload("sync_state", "Synced", {
        "world_id": state.ACTIVE_WORLD_ID,
        "character_id": state.ACTIVE_CHARACTER_ID,
        "available_worlds": world_list,
        # ... all state fields
    })
)

# Token streaming
for token in llm_stream:
    await websocket.send_text(
        build_ws_payload("chat_chunk", token, {"tokens_per_second": tps})
    )

Rust Client

let json_msg = serde_json::json!({ "prompt": user_input });
write.send(Message::Text(json_msg.to_string())).await?;

Next Steps

Architecture Overview

See how components interact

Split-Brain Design

Understand the Python/Rust separation

System Rules

Learn the narrative constraints

Development Guide

Build your own extensions

Build docs developers (and LLMs) love