A workflow lives in one directory, workflows/my-workflow/: a YAML form plus job
graph around scripts that already work on the target machine. What a run leaves
behind is the workflow's shape, and the shape decides which jobs follow the first
one:
| Shape | The run ends when | Copy from | Reference |
|---|---|---|---|
| Endpoint service (most workflows; this guide) | the service answers its URL; it keeps serving until pw endpoints delete |
workflows/webshell |
shapes/endpoint.md |
| Batch job | the script finishes; the run fails when it fails | workflows/activate-batch |
shapes/batch.md |
| Callable step (login node only) | its steps finish; one general.yaml for every platform |
workflows/doe, workflows/design-explorer |
shapes/composed.md |
| Composed | the study or loop finishes | workflows/doe-openfoam, workflows/dakota-openfoam |
shapes/composed.md |
| Kubernetes | never: cancelling the run is the teardown | workflows/mlflow/yamls/k8s.yaml |
k8s-workflows.md |
This guide walks the endpoint service; §3 says where the other shapes differ. The rules every shape shares, and how the shapes combine, are in AGENTS.md.
Apart from Kubernetes, every shape works the same way: the controller script installs
what the workflow needs into a shared directory on the login node
(service_parent_install_dir, default ${HOME}/pw/software, visible from the compute
nodes), and the script that does the work is handed to the script_submitter
subworkflow, which runs it on the login node or submits it to SLURM/PBS. Call a
subworkflow for anything one already does (script_submitter, wait_for_endpoint,
the sampler, the optimizer step, the case runner, the study page) instead of
re-implementing it, and write your workflow so others can call it in turn: its form is
its interface, and the paths it reads and writes are inputs.
An endpoint workflow needs three things:
| File | Runs on | Purpose |
|---|---|---|
app/controller.sh |
Controller (login) node | Install software, download dependencies |
app/start-template.sh |
Controller or compute node | Start the web service behind pw endpoints run |
yamls/<variant>.yaml (e.g. yamls/general.yaml) |
Platform | Define the UI form, generate inputs.sh, orchestrate |
A batch workflow submits a run template instead of a start template
(run-template.sh, train-template.sh, or a script preprocessing writes from the
form); a callable step may need no scripts at all.
app/ holds everything the run needs (scripts + support files) and is the only
subtree the workflow sparse-checkouts; yamls/, thumbnails/, tests/, README, and
build tooling stay outside it.
The controller node always has internet access. The compute node may not.
The platform docs are the source of truth — consult them when anything here is
unclear or behaves differently:
building workflows
(YAML fields ·
inputs & expressions ·
actions) ·
endpoint sessions ·
pw endpoints CLI.
An app that signs its users in with their ACTIVATE account (OIDC): the recipe and the
proxy constraints are in
references/oidc-sign-in.md;
workflows/openproject is the worked example.
Develop on the target machine first. Pick the resource (pw cluster ls, it must be
active), run the program there by hand (pw ssh <resource>) until it starts from a
clean shell with no manual setup, then wrap it. The YAML is a thin wrapper around code
that already works.
Working with an AI assistant? The repo ships a Claude Code skill —
.claude/skills/activate-workflows/ — that encodes this process plus the platform
reference. In Claude Code: "Using the activate-workflows skill, build a workflow for
[deployment] that [does X]." Before the first test run or the PR, "Use the
workflow-reviewer subagent on workflows/<name>" gets a read-only review against
AGENTS.md's rules, the shape's reference and the recorded tests
(.claude/agents/workflow-reviewer.md).
workflows/my-workflow/app/controller.sh runs before the service starts, on the
login node. Use it for anything needing internet, and install under
service_parent_install_dir, which the compute nodes see too. All inputs.sh
variables are available.
#!/usr/bin/env bash
set -o pipefail
if [ -z ${service_parent_install_dir} ]; then
service_parent_install_dir=${HOME}/pw/software
fi
if ! [ -f "${service_parent_install_dir}/my-server" ]; then
echo "Installing my-server..."
mkdir -p ${service_parent_install_dir}
wget https://example.com/my-server.tar.gz -O /tmp/my-server.tar.gz
tar -xzf /tmp/my-server.tar.gz -C ${service_parent_install_dir}
fiKeep it idempotent — check whether software exists before installing.
workflows/my-workflow/app/start-template.sh starts the web service behind
pw endpoints run, which picks a free port, registers the endpoint and forwards its
URL to the service. All inputs.sh variables are available, among them
pw_endpoints_args (--name <service.name>-${PW_RUN_SLUG}, written by preprocessing).
#!/bin/bash
if [ -z ${service_parent_install_dir} ]; then
service_parent_install_dir=${HOME}/pw/software
fi
# {port} is replaced by pw endpoints run with the port it forwards to; the
# command stays in the foreground for the life of the service
pw endpoints run ${pw_endpoints_args} -- \
${service_parent_install_dir}/my-server --host 0.0.0.0 --port {port}
if [ $? -ne 0 ]; then
# pw endpoints run also returns non-zero when the run cancels this job after a
# successful launch (that is how wait_for_endpoint releases the run): only a
# launch that never registered its endpoint is a failure
served_name=$(printf '%s' "${pw_endpoints_args}" | sed -n 's/.*--name[ =]\{1,\}\([^ ]*\).*/\1/p')
if [ -n "${served_name}" ] && pw endpoints list 2>/dev/null | awk '{print $1}' | grep -qxF "${served_name}"; then
exit 0
fi
echo "::error title=Error::pw endpoints command failed"
exit 1
fiRequirements:
- Bind
0.0.0.0on the portpw endpoints runassigns: the{port}token in the command, or$PORTin the environment of the wrapped command.PW_ENDPOINT_URL,PW_ENDPOINT_HOSTandPW_ENDPOINT_PATHare there too, for apps that build absolute URLs: generate a small launcher and runpw endpoints run ${pw_endpoints_args} -- ./launch.sh. - Stay in the foreground for the life of the service:
pw endpoints runblocks; a server started in the background needs awaitafter it. - Exit non-zero when the endpoint never registered, and never cancel the run from
inside the script (the tail above, from
workflows/webshell/app/start-template.sh). - A check that must fail the run goes before
pw endpoints run. The run completes the moment the URL answers, so a check made after that fails a run that is already complete. Start the service on a free loopback port, check it, thenpw endpoints run --port <port> -- <foreground command>(workflows/pgadmin4). cancel.sh, first thing, for anything the process-tree kill will not reach: ascreensession, a docker container, an app that daemonizes and re-parents to PID 1 (RStudio'srsession). The submitter runs it on cancel. A service with nothing of the kind needs none (webshellwrites one only when it attaches toscreen).- A page the workflow draws itself looks like the platform. Vendor the theme
contract of
@parallelworks/uiwithbash tools/ui/sync-theme.sh workflows/my-workflow/app/web(the--theme-*tokens and the Geist fonts), write the page's stylesheet against those tokens following the package's component recipes, and draw its icons inline: references/app-ui.md;workflows/probe/app/webis the worked example. A third-party app keeps its own UI.
A batch script does something else: it records its own exit status, because
script_submitter never forwards it,
trap 'echo $? > ${PWD}/commands.exit' EXITand a step after the submitter fails the run when the file is missing or non-zero
(workflows/activate-batch/yamls/general.yaml; signals, wait $! and scancel in
shapes/batch.md).
workflows/my-workflow/yamls/general.yaml. Its jobs:
- preprocessing —
parallelworks/checkoutof this repo (sparse:workflows/my-workflow/app, plustools/...if the scripts use the shared tools), generateinputs.shfrom the form values +PW_*environment, runinputs.sh + controller.shinline, and assemble the start script (inputs.sh+ a cleanup trap +start-template.sh). - session_runner (the job name kept for history) — submit the start script via
workflows/script_submitter/v3.6/<variant>.yaml(uses: github/parallelworks/workflows@canary). - wait_for_endpoint — calls the
wait_for_endpointsubworkflow (uses: github/parallelworks/workflows@canary,$yaml: workflows/wait_for_endpoint/general.yaml), then cancels the submitter. The subworkflow pollspw endpoints listfor<service.name>-${PW_RUN_SLUG}, probes the URL with the run's key until the status matcheshealthy(withinbudget), and touches theSKIP_CLEANUPfile so the service outlives the run. If the service never answers, the job fails without the skip file and the submitter's cleanup tears the job down. The workflow, not the test runner, owns this check. Sethealthy(a web UI answers 2xx/3xx),path(an API server's health route) andbudget(minutes for a container or a model load) per service and verify them in a test. Inputs: workflows/wait_for_endpoint/README.md.
Copy a real one instead of writing from scratch —
workflows/webshell/yamls/general.yaml is the smallest complete example;
workflows/jupyterlab/yamls/general.yaml shows a conda install plus support files;
workflows/streamlit/yamls/general.yaml + its scripts show a Singularity/SIF
service (SIF pulled via oras, .def + build-container.sh alongside).
Key parts to adapt:
- the hidden
service.nameinput (endpoint name prefix), - the
healthy,budgetandpathinputs of thewait_for_endpointcall, - the sparse-checkout paths (
workflows/my-workflow/app,tools/...), - the
cat workflows/my-workflow/app/controller.sh/start-template.shlines, - the
serviceinput group (your form fields →inputs.shvariables), - the form text: labels in sentence case, booleans as statements, a one-line
descriptionfor what a user must know before submitting, the detail in thetooltip(AGENTS.md, "Form text").
The other shapes keep preprocessing and change what follows it:
- Batch job — one
script_submittercall (noskip_cleanups_file), then a step or aresultsjob that reads the exit file the script wrote and fails the run with an::errorwhen it is missing or non-zero. Copyworkflows/activate-batch(smallest) orworkflows/benchmarks(a results job with figures). - Callable step — plain
run:steps on the login node, onegeneral.yaml, and a form that is its interface: every path it reads or writes is an input, so a caller passes paths under its own job dir. When it serves a page, it startspw endpoints rundetached from a step (setsid), callswait_for_endpoint, and adds astop_server_if_unhealthyjob underif: ${{ !success() }}(workflows/design-explorer; the same wiring serves TensorBoard beside the training job ofworkflows/physicsnemo). - Composed — a YAML with no code of its own:
uses:of the workflows above with their form as thewith:block, a matrix of evaluators, a retrieduses:step as the loop (workflows/doe-openfoam,workflows/dakota-openfoam; the mechanics step by step intutorials/optimization/). - Kubernetes — the same endpoint is registered by a
pw-clisidecar in the pod, the run stays alive streaming pod logs, and cancelling the run is the teardown; see .claude/skills/activate-workflows/references/k8s-workflows.md.
One YAML per deployment: yamls/general.yaml (standard SLURM/PBS clusters), plus
emed.yaml / hsp.yaml / noaa.yaml where the workflow is offered there, and
k8s.yaml / general_k8s.yaml for Kubernetes. Variants differ in scheduler
directives, partitions, module loads, and defaults — copy the matching variant of a
similar workflow (they pass their variant's
workflows/script_submitter/v3.6/<variant>.yaml). emed, hsp and noaa are meant
for cluster.einsteinmed.edu, activate.hpc.mil and noaa.parallel.works, but they
also run on the cloud clusters of activate.parallel.works, which is how their tests
are recorded. A workflow that only runs on the
login node and submits no scheduler job keeps a single general.yaml (doe, dakota,
design-explorer): nothing differs between platforms, so a caller on any platform
calls that one file.
Every workflow is tested end-to-end at least once, and any end-to-end test is recorded
under workflows/my-workflow/tests/<variant>/. The test layout, the test file keys, the
pass criteria and the result columns are documented once, in
tools/tests/README.md. Start from an existing test, e.g.
workflows/webshell/tests/<variant>/<test-name>.json.
Push first — the YAML pulls this repo from GitHub at run time, so local edits to
app/ are invisible until they are on the referenced branch. Then:
python3 tools/tests/run-workflow-test.py workflows/my-workflow/tests/<variant>/<test-name>.jsonCommit the test and the rows the runner appends to its CSV with your change; never edit a CSV by hand. What pass means depends on the shape:
- An endpoint test passes when the run completes and the endpoint is listed. The
runner never probes the URL: a run only completes after the workflow's own health
check, and the wait job's output (
pw workflows runs logs <slug> --job wait_for_endpoint) shows the status codes it saw. - A workflow that registers no endpoint sets
_test.endpoint: falseand passes when the run completes. - A failure-path test (an
exit 1before the service starts, an input the form forbids) sets_test.expect: error. Record one: the success path never exercises the error-handling steps. - A Kubernetes test (
tests/k8s/,tests/general_k8s/) passes when the wait job completes while the run is still running; the runner cancels the run to tear it down.
Verify cleanup on cancel — part of testing, every time. Cancel a run mid-flight
(pw workflows runs cancel <slug> while the service is starting or serving) and
confirm the cleanup actually ran: no service processes left (ps -x), no scheduler
job (squeue/qstat when scheduler:true), no container instances
(singularity instance list, docker ps), no stray listeners, no stopped record
left in pw endpoints list. Apps that daemonize and re-parent to PID 1 (e.g. RStudio's
rsession) survive the tree kill and need cancel.sh (§2).
While iterating, point the YAML's checkout branch: at a development branch and
restore it to canary before the PR merges (canary only accepts pull requests).
Everything a run did is in its job dir on the execution node:
~/pw/jobs/<run-slug>/ for CLI file runs, or
~/pw/jobs/<workflow-name>/<run-number, 5 digits>/ for registered workflows.
run.<JOBID>.out— the submitted script's stdout/stderr (the service's, or the batch job's)logs/<job>/step_N/step.out,step.exit— per-step trace and exit codelogs/<job>/step_N/script-unstable.sh— the rendered step: every${{ input }}appears as the literal value the form sent. When a value seems ignored (a default not applied, an empty field), read this first.- From any machine:
pw workflows runs errors <slug>andpw workflows runs logs <slug>.
- Testing unpushed code — the checkout fetches GitHub, not your working tree.
- Relative YAML path in
pw workflows run— parsed as a git host; use absolute. - Composing checkout paths wrong — checked-out files materialize at
${PW_PARENT_JOB_DIR}/workflows/<name>/app/…(or…/<impl>/…), including paths built from variables ("${PW_PARENT_JOB_DIR}/${service_name}"-style bugs surface only at run time). - Globs copy everything in the directory they target. Some preprocessing steps
collect support files with a glob — e.g. jupyter runs
cp workflows/jupyter/app/*.yaml .to gather its conda-env files. That only works becauseapp/contains nothing but runtime files: the workflow's own variant YAMLs live inyamls/, outsideapp/. Keep it that way — a workflow YAML (or any unrelated file) placed insideapp/would be swept into every run. - A
--dry-runthat passes proves little — it validates the YAML and each subworkflow's required inputs, not unknown fields or a wrong variant; only a real run tests a workflow. - Single-attempt ghcr pulls — ghcr intermittently rate-limits anonymous pulls;
use
tools/oras/libs.sh:oras_pull_file(it retries) and keep packages public. - A registered workflow ignoring your defaults — the registration pins one YAML
path (
pw workflows get <name>→remote.yaml); if it points at the wrong variant, the form (and its defaults) are the wrong variant's. - "Authentication has expired" —
pwtokens lapse; re-runpw auth.
To bring an older session-tunnel workflow onto the endpoint shape, follow
.claude/skills/activate-workflows/references/session-to-endpoint-upgrade.md; which
workflows those are and where they live is in MIGRATION.md.