Your First Workflow
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
uvinstalled (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:
[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
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.
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:
[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:
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
@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
- Deploy your workflow — bundle and ship with
scripts/deploy.py. - Set up CI/CD — automate dev/stg/prd deploys on GitHub Actions or GitLab CI.
- Add error handling — make failures surface cleanly.
- Add human-in-the-loop steps — use W02 as the reference pattern.