UniteLabs
Automate

Your First Workflow

Clone the workflow template, run the hello-world example locally, and scaffold your own first workflow in under 15 minutes.

In this guide you'll clone the workflow template, run its hello-world example locally to confirm your environment works, and copy it into a new workflow of your own.

Prerequisites

  • Python 3.12 or newer
  • uv installed (latest)
  • A VS Code based IDE
  • A UniteLabs account with API credentials (only needed when you deploy — not for local runs)
  • GitLab SSH access for the unitelabs-* SDK libraries

Clone the template

git clone https://gitlab.com/unitelabs/workflows/workflow-template.git
cd workflow-template

The template ships three reference workflows side-by-side:

workflow-template/
├── shared/                 # shared library (config, custom labware, reusable @task steps)
├── w01-hello-world/        # the example we'll use here
├── w02-liquid-handling/    # real liquid handling demo (runs in simulation)
├── w03-plateloc-sealer/    # human-in-the-loop demo
└── scripts/deploy.py       # the deploy CLI

Each w*-*/ directory is a standalone Python package — with its own pyproject.toml, its own uv.lock, its own .venv. The shared/ library is consumed via a path dependency so edits there are picked up by every workflow without a re-sync.

Sync the dependencies

Sync shared first, then the workflow you want to run:

uv sync --directory shared
uv sync --directory w01-hello-world

This creates w01-hello-world/.venv/ with the UniteLabs workflow engine, the UniteLabs SDK pins from the workflow's pyproject.toml, and shared installed editable.

Tour w01-hello-world

Every workflow has the same three pieces.

1. The package metadata — pyproject.toml

The interesting bit is [tool.unitelabs.workflow] — that's the contract with the deploy script:

w01-hello-world/pyproject.toml
[project]
name = "w01-hello-world"           # the workflow's slug
version = "1.0.1"                  # bump this when you release
description = "Sanity check that the SDK is wired correctly."
dependencies = [
    "shared",
    "prefect>=3.2.9,<4.0.0",
    "unitelabs-sdk~=0.6.0",
    # ...
]

[tool.unitelabs.workflow]
display_name = "[deploy-test] Hello World"     # what shows on the platform
entrypoint = "workflow.py:hello_world_flow"    # <file>:<func> inside the package
tags = ["deploy-test"]

[tool.uv.sources]
shared = { path = "../shared", editable = true }

The deploy script reads [project].name, [project].version, and [tool.unitelabs.workflow] directly from this file. There's no central manifest to keep in sync.

2. The flow — src/w01_hello_world/workflow.py

w01-hello-world/src/w01_hello_world/workflow.py
from unitelabs.labware import __version__ as labware_version
from unitelabs.liquid_handling import __version__ as liquid_handling_version
from unitelabs.sdk import __version__ as sdk_version
from unitelabs.sdk import workflow, get_logger


@workflow(
    name="Hello World",
    retries=0,
)
async def hello_world_flow(recipient_name: str = "world") -> None:
    """Log a greeting and SDK version info to confirm the environment works."""
    logger = get_logger()
    logger.info(f"Hello, {recipient_name}!")
    logger.info(f"UniteLabs SDK:             {sdk_version}")
    logger.info(f"UniteLabs Labware:         {labware_version}")
    logger.info(f"UniteLabs Liquid Handling: {liquid_handling_version}")

A workflow is just an async function decorated with @workflow. Larger workflows split into phases (also @phase-decorated) and steps (@step-decorated, often coming from shared/steps/) — see the Workflow → Phase → Step taxonomy.

3. The entrypoint — src/w01_hello_world/__main__.py

This is what uv run --directory w01-hello-world workflow calls — a thin wrapper that runs hello_world_flow via asyncio.run.

Run it locally

uv run --directory w01-hello-world workflow

You should see the log output with the greeting and three SDK version lines. No credentials, no hardware.

The workflow console script is declared in each workflow's pyproject.toml under [project.scripts] — every workflow exposes the same entry, so the run command is identical regardless of which workflow you point at.

Create your own first workflow

The mechanical recipe — copy w01-hello-world to your own w**-... directory and add it as a separate workspace in workflow-template.code-workspace. Then update the new workflow's pyproject.toml:

w02-sample-quality/pyproject.toml
[project]
name = "w02-sample-quality"
version = "0.1.0"
description = "Fetch samples and evaluate them against a concentration threshold."

[project.scripts]
workflow = "w02_sample_quality.__main__:main"   # match the new package name

[tool.unitelabs.workflow]
display_name = "Sample Quality"
entrypoint = "workflow.py:sample_quality_flow"
tags = ["qc"]

And rewrite workflow.py with the flow of your own — for example, a quality check that fetches samples and applies a concentration threshold:

w02-sample-quality/src/w02_sample_quality/workflow.py
from unitelabs.sdk import (
    workflow,
    phase,
    step,
    get_logger
)

@step(name="Fetch Samples")
async def fetch_samples_task(source: str) -> list[dict]:
    logger = get_logger()
    logger.info(f"Fetching samples from {source}")
    # Replace with your actual data retrieval.
    return [
        {"id": "sample_001", "concentration": 450.0},
        {"id": "sample_002", "concentration": 210.0},
        {"id": "sample_003", "concentration": 890.0},
    ]


@step(name="Evaluate Samples")
async def evaluate_samples_task(samples: list[dict], min_concentration: float) -> dict:
    passed = [s for s in samples if s["concentration"] >= min_concentration]
    failed = [s for s in samples if s["concentration"] < min_concentration]
    return {
        "passed": passed,
        "failed": failed,
        "pass_rate": len(passed) / len(samples) if samples else 0.0,
    }


@phase(name="Sample Quality", retries=0)
async def sample_quality_flow(
    source: str = "data_warehouse",
    min_concentration: float = 300.0,
) -> dict:
    """Fetch samples and evaluate them against a concentration threshold."""
    samples = await fetch_samples_task(source=source)
    return await evaluate_samples_task(samples=samples, min_concentration=min_concentration)

Sync and run:

uv sync --directory w02-sample-quality
uv run --directory w02-sample-quality workflow
Keep each @step focused on one responsibility — fetch, transform, persist, notify. Orchestrate them in the @phase, not inside the steps themselves. The UniteLabs workflow engine runs independent steps concurrently.

Next steps