Skip to content

Getting Started ​

This guide walks through one complete Culsma run: write a representative flow-cytometry protocol, run it, read the terminal result, save execution artifacts, and replay the run.

Before starting, install Culsma and confirm that culsma --help works. See Install Culsma for the short install path.

What You Will Do ​

  • create a protocol source file
  • define containers with canonical content kind, type, and attrs
  • model staining, washing, fixation, and acquisition preparation
  • use repeat, sep, with env, markers, stream, and readout schema syntax
  • run the protocol through the kernel pipeline
  • inspect the returned acquisition sample and saved artifacts
  • replay the runtime event stream

Create a Protocol File ​

Create a file named flow_cytometry_protocol.culs:

text
protocol FlowCytometryProtocol {
    // A compact, generic flow-cytometry immunophenotyping protocol.
    // The example demonstrates material setup, washing, staining, fixation,
    // single-cell stream construction, and a structured readout declaration.

    // Prepare the cell input and process reagents.
    let cell_suspension = tube(
        label = "PreparedCellSuspension",
        capacity = 2000uL,
        load = [content(kind = bio_cellular, type = cell_population, code = "CELLS1", attrs = { state: suspension }):500uL]
    );
    let lysis_buffer = tube(
        label = "RedCellLysisBuffer",
        capacity = 3000uL,
        load = [content(kind = formulation, type = buffer, code = "RCLB", attrs = { role: lysis }):2500uL]
    );
    let staining_buffer = tube(
        label = "StainingBuffer",
        capacity = 5000uL,
        load = [content(kind = formulation, type = buffer, code = "STAINBUF", attrs = { role: wash }):4500uL]
    );
    let acquisition_buffer = tube(
        label = "AcquisitionBuffer",
        capacity = 2000uL,
        load = [content(kind = formulation, type = buffer, code = "ACQBUF", attrs = { role: resuspension }):1500uL]
    );
    let viability_dye = tube(
        label = "ViabilityDye",
        capacity = 200uL,
        load = [content(kind = chemical, type = dye, code = "LIVEDEAD", attrs = { role: stain }):100uL]
    );
    let antibody_cocktail = tube(
        label = "FluorAntibodyCocktail",
        capacity = 500uL,
        load = [content(kind = bio_molecule_or_virus, type = protein, code = "AB_CD45_CD3_CD19", attrs = { role: antibody }):300uL]
    );
    let fixation_buffer = tube(
        label = "FixationBuffer",
        capacity = 1000uL,
        load = [content(kind = formulation, type = buffer, code = "FIX", attrs = { role: fixation }):800uL]
    );
    let waste = tube(label = "FlowWaste", capacity = 10000uL);
    let working_cells = tube(label = "WorkingCellSuspension", capacity = 5000uL);
    let acquisition_sample = tube(label = "AcquisitionSample", capacity = 3000uL);

    // Combine cells with lysis buffer and incubate at room temperature.
    working_cells << [cell_suspension:200uL, lysis_buffer:1800uL] with constraint(high_precision, low_carryover);
    with env(thermal = 25C, duration = 10min) {
        hold(working_cells);
    }

    // Retain the cell-containing fraction and route process liquid to waste.
    let lysis_split = sep(
        sample = working_cells,
        program = centrifugal_filtration_program(
            membrane = "cell_retention", drive = 400g, duration = 5min
        ),
        component_fates = { CELLS1: { filtrate: 0%, retentate: 100% } }
    );
    waste << [lysis_split[0]];
    working_cells << [lysis_split[1]];

    // Wash retained cells twice before staining.
    repeat wash_cycle in schedule(start = 1, end = 2, step = 1) {
        working_cells << [staining_buffer:1000uL];
        let wash_split = sep(
            sample = working_cells,
            program = centrifugal_filtration_program(
                membrane = "cell_retention", drive = 400g, duration = 5min
            ),
            component_fates = { CELLS1: { filtrate: 0%, retentate: 100% } }
        );
        waste << [wash_split[0]];
        working_cells << [wash_split[1]];
    }

    // Add viability dye and antibody cocktail under dark-protected conditions.
    working_cells << [viability_dye:5uL, antibody_cocktail:50uL] with constraint(high_precision, low_carryover, dark_protected);
    with constraint(dark_protected) {
        with env(thermal = 4C, duration = 30min) {
            hold(working_cells);
        }
    }

    // Fix stained cells, then prepare the acquisition sample.
    working_cells << [fixation_buffer:300uL];
    with constraint(dark_protected) {
        with env(thermal = 25C, duration = 10min) {
            hold(working_cells);
        }
    }

    acquisition_sample << [working_cells:300uL, acquisition_buffer:700uL];

    // Declare the marker panel, single-cell event stream, and readout schema.
    let immunophenotyping_panel = markers([CD45, CD3, CD19, LiveDead]);
    let cell_events = stream(sample = acquisition_sample, unit = single_cell, panel = immunophenotyping_panel);
    let flow_event_schema = data_schema(
        label = "FlowCytometryEventReadout",
        fields = [event_id, sample_id, fsc_a, ssc_a, cd45_signal, cd3_signal, cd19_signal, viability_signal, population_label]
    );
    let flow_events = img(sample = cell_events, quantity = customized, schema_ref = flow_event_schema, save_raw = true);

    return acquisition_sample;
}

let acquisition_sample = FlowCytometryProtocol();
return acquisition_sample;

This example models a compact, generic flow-cytometry immunophenotyping protocol. It keeps the protocol small enough for a first run while exercising the same source surface used by larger protocols: canonical content taxonomy, metadata attrs, material transfer, separation, environment blocks, repeat scheduling, marker panels, stream construction, and readout declaration.

Run It ​

From the directory containing flow_cytometry_protocol.culs, run:

bash
culsma flow_cytometry_protocol.culs

The shorthand above is equivalent to:

bash
culsma run flow_cytometry_protocol.culs

Expected terminal output:

text
entry ok

return:
  AcquisitionSample (tube)
    volume: 1000 uL
    mass: 1000 mg

execution: 46/46 steps completed, 0 diagnostics

Read the Result ​

The result has four useful signals:

OutputMeaning
entry okThe source parsed, validated, planned, and ran successfully.
AcquisitionSample (tube)The protocol returned the prepared acquisition sample container.
volume: 1000 uLThe runtime tracked the final material amount in that container.
46/46 steps completed, 0 diagnosticsEvery generated execution step completed without diagnostics.

The returned tube is the protocol output. It is separate from the generated run report and debug artifacts.

Read the Source ​

The protocol is a compact flow-cytometry protocol. These are the main source sections to inspect.

Content Initialization ​

text
load = [content(kind = bio_cellular, type = cell_population, code = "CELLS1", attrs = { state: suspension }):500uL]

kind and type identify the broad material class and refinement. code is a stable component identifier. attrs records additional metadata such as preparation state or protocol role.

Material Preparation ​

text
working_cells << [cell_suspension:200uL, lysis_buffer:1800uL] with constraint(high_precision, low_carryover);
with env(thermal = 25C, duration = 10min) {
    hold(working_cells);
}

The << operator transfers material into a destination container. The with constraint(...) clause records execution requirements, and with env(...) applies temperature and duration context to the enclosed hold.

Separation and Washing ​

text
let lysis_split = sep(
    sample = working_cells,
    program = centrifugal_filtration_program(
        membrane = "cell_retention", drive = 400g, duration = 5min
    ),
    component_fates = { CELLS1: { filtrate: 0%, retentate: 100% } }
);
waste << [lysis_split[0]];
working_cells << [lysis_split[1]];

centrifugal_filtration_program(...) describes a membrane run driven by centrifugation; its outputs are filtrate (0) and retentate (1). The tutorial routes filtrate to waste and retains the cell-containing material. The repeat block applies the same wash pattern twice.

component_fates explicitly declares ideal cell retention: all CELLS1 material enters the retentate. The built-in model routes the free buffer into the filtrate. A membrane name alone does not establish cell retention, and Culsma does not guess a split when the required facts are missing. The declared ratios and run settings illustrate this tutorial's bookkeeping assumptions; they do not predict experimental recovery. See Per-Content Fate Rules.

The top-level call explicitly selects FlowCytometryProtocol for execution, and return acquisition_sample; returns its result from the entry script.

Staining and Fixation ​

text
working_cells << [viability_dye:5uL, antibody_cocktail:50uL] with constraint(high_precision, low_carryover, dark_protected);

The viability dye is represented as chemical/dye with attrs = { role: stain }. The antibody cocktail is represented as bio_molecule_or_virus/protein with attrs = { role: antibody }. These roles are metadata for protocol intent; they do not imply biological prediction.

Stream and Readout ​

text
let immunophenotyping_panel = markers([CD45, CD3, CD19, LiveDead]);
let cell_events = stream(sample = acquisition_sample, unit = single_cell, panel = immunophenotyping_panel);

The marker panel and stream declaration model the acquisition surface. The final img(...) call declares a structured readout schema for event-level signals.

Save Execution Artifacts ​

Run again with an artifacts directory:

bash
culsma run flow_cytometry_protocol.culs --artifacts-dir tmp/flow-cytometry-run

The directory contains staged pipeline and runtime artifacts:

FileMeaning
ast.jsonParsed source program.
ir.jsonCanonical IR after compile.
validate.jsonSemantic validation diagnostics.
typecheck.jsonType and unit diagnostics.
plan.jsonExecutable plan.
run.jsonRuntime state, events, diagnostics, and user result.
output.jsonMachine-readable protocol return plus report.
result.jsonDerived lab_report_v1 report summary.

Replay It ​

Replay reconstructs runtime state from the saved event stream:

bash
culsma replay --run-json tmp/flow-cytometry-run/run.json --out tmp/flow-cytometry-run/replayed_state.json

For this example, replay reconstructs 46 executed steps from 94 runtime events.

Next Steps ​

Released under the Apache-2.0 license.