Cersei

Workflows Overview

A first-party, serializable workflow engine for Cersei — author multi-step pipelines in Rust or draw them in a visual builder (React + xyflow). One IR, two front-ends.

Workflows

Workflows let you define a multi-step pipeline as an explicit graph of steps instead of leaning on a single agent's reasoning to hold the whole plan. You decide what runs, in what order, how data flows between steps, and what happens on a branch or a failure. A workflow can call plain Rust functions, agents, tools, or other workflows.

The defining property of cersei-workflows is that the entire workflow is serializable data. A workflow is a WorkflowDef — a flat list of nodes and edges — that round-trips losslessly to and from JSON. That is what lets a visual builder (React + xyflow / React Flow) draw a workflow, emit it as JSON, and have the Rust runtime execute it. It is the engine behind the Atlas workflow UI.

One IR, two front-ends. The programmatic WorkflowBuilder and the visual builder produce the same WorkflowDef. There is one compiler (Workflow::compile) and one executor. Anything you can draw, you can write — and vice versa — because they are the same data.

When to use a workflow

Use a workflow when the steps are known up front and the order matters — you want fine-grained control over data flow and which primitive runs at each stage. Use a plain Agent when the path is open-ended and you want the model to decide what to do next. The two compose: a workflow step can run an agent, and an agent can be handed a workflow as a tool.

The shape of a workflow

  input ──▶ [validate] ──▶ ⟨branch⟩ ──when premium──▶ [enrich] ──▶ [summarize] ──▶ result

                              └──────else───────────▶ [summarize] ─────────────────▶ result

  parallel block:
                 ┌─▶ [fetch_a] ─┐
  input ──▶ «par»┤              ├─▶ «join» ──▶ [merge] ──▶ result
                 └─▶ [fetch_b] ─┘

Every box is a node in the WorkflowDef:

  • Step — runs a registered Step by id (a function, agent, tool, or nested workflow).
  • Parallel / Join — fan out to several branches concurrently, then collect (AllOrFail or AllSettled).
  • Branch — evaluate conditions in order; the first match wins.
  • Loopdowhile / dountil / foreach.
  • Map — a pure JSON reshape between steps.

Core pieces

WorkflowDef (the IR)

A serializable { nodes, edges, entry } graph. The single source of truth, emitted identically by the builder and the UI. UiHints (x/y/label) ride along but are ignored at execution time, so the IR is lossless across the React Flow boundary.

Step trait

Shaped exactly like a Tool: an id, input/output JSON schemas, and an async execute. First-party impls: FnStep, AgentStep, ToolStep, WorkflowStep.

StepRegistry

Maps step-ids to executable implementations. UI JSON carries only references; the host supplies the code. catalog() exposes the palette of available steps to the builder.

WorkflowEvent / Stream

A Serialize-able event stream (StepStarted, StepCompleted, BranchTaken, StateUpdated, …) for lighting up the live graph over SSE or WebSocket.

A first workflow

Register the steps, author the graph, compile, run.

use cersei::workflows::prelude::*;
use serde_json::json;
use std::sync::Arc;

#[tokio::main]
async fn main() -> cersei::types::Result<()> {
    // 1. Register executable steps by id.
    let registry = StepRegistry::new();
    registry.register(Arc::new(FnStep::new("upper", |input, _ctx| async move {
        let s = input.get("message").and_then(|v| v.as_str()).unwrap_or("");
        Ok(json!({ "message": s.to_uppercase() }))
    })));
    registry.register(Arc::new(FnStep::new("emphasize", |input, _ctx| async move {
        let s = input.get("message").and_then(|v| v.as_str()).unwrap_or("");
        Ok(json!({ "message": format!("{s}!!!") }))
    })));

    // 2. Author the workflow (the UI emits this very same WorkflowDef as JSON).
    let def = WorkflowBuilder::new("greet")
        .then("upper")
        .then("emphasize")
        .commit();

    // 3. Compile against the registry and run.
    let wf = Workflow::compile(def, &registry)?;
    let result = wf.start(json!({ "message": "hello world" })).await?;

    assert_eq!(result.status, RunStatus::Success);
    println!("{}", result.result.unwrap()); // {"message":"HELLO WORLD!!!"}
    Ok(())
}

Round-tripping through the UI

Because WorkflowDef is plain serde data, the same workflow is just JSON. The visual builder draws nodes/edges and serializes exactly this; the host deserializes it and calls Workflow::compile.

let def = WorkflowBuilder::new("greet").then("upper").then("emphasize").commit();

// Send to the UI / store it / receive it back — losslessly.
let wire = serde_json::to_string(&def)?;
let same: WorkflowDef = serde_json::from_str(&wire)?;
assert_eq!(def, same);
{
  "id": "greet",
  "entry": "upper_1",
  "nodes": [
    { "id": "upper_1", "kind": { "step": { "step_id": "upper", "config": null } } },
    { "id": "emphasize_2", "kind": { "step": { "step_id": "emphasize", "config": null } } }
  ],
  "edges": [
    { "from": "upper_1", "to": "emphasize_2", "kind": "then" }
  ]
}

The IR enums are externally tagged on purpose ({ "step": { … } }, "then"). Internally-tagged serde enums route through a content buffer that blows up serde_json serialization with an unbounded recursion_limit overflow — and that overflow surfaces in your crate, not ours. Keep these externally tagged.

Live status for the builder

stream() returns a WorkflowStream of WorkflowEvents. Every event is Serialize, so you can forward them straight to a browser over SSE/WebSocket to animate the graph.

let wf = Workflow::compile(def, &registry)?; // wf: Arc<Workflow>
let mut stream = wf.stream(json!({ "message": "hi" }));

while let Some(event) = stream.next().await {
    // Forward to the UI as JSON.
    let frame = serde_json::to_string(&event)?;
    // ws.send(frame).await?;
}

Enable the feature

cersei-workflows is opt-in on the facade:

[dependencies]
cersei = { version = "0.2.1", features = ["workflows"] }

Then use cersei::workflows::prelude::*; (the common types are also re-exported from cersei::prelude). Or depend on the crate directly:

cersei-workflows = "0.2.1"

Where to go next

On this page