Write nextflow local modules
Skill vagkaratzas/skills/skills/write-nextflow-local-modules
How to create or update local Nextflow modules for any pipeline. Use this skill whenever the user asks to create, write, scaffold, add, or update local Nextflow modules — even if they just say "add a module for X" or "write a module that does Y". Local modules live at modules/local/<name>/ and resemble nf-core modules but with relaxed conventions: no nf-core lint required (only nextflow lint), local test fixtures instead of a separate test-datasets repo, nf-test process tests by default, and the modern topic-channel version output instead of versions.yml.From its SKILL.md
npx -y skills add vagkaratzas/skills --skill write-nextflow-local-modulesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 4 stars4 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
15.6 KB, as published. Nobody here has run it
Writing Local Nextflow Modules
If following nf-core standards, local modules live alongside nf-core modules in modules/local/<module_name>/
and follow the same structural conventions, but without nf-core linting requirements (nextflow lint only).
Directory scaffold
bin/
└── <script_name>.py # executable custom logic called by module script blocks
modules/local/<module_name>/
├── main.nf # process definition
├── meta.yml # module documentation
├── environment.yml # conda dependencies
└── tests/
├── main.nf.test # nf-test file
├── main.nf.test.snap # snapshot (generated by running nf-test)
└── fixtures/ # minimal local test data
└── <test_input>.<ext>
Create bin/ whenever the module needs custom Bash, Python, R, or other scripting logic beyond a
simple one-line command. Keep substantial custom logic out of modules/local/<module_name>/main.nf
by default; the module should expose the Nextflow interface and call a script from bin/.
Create tests/main.nf.test, tests/main.nf.test.snap, and minimal tests/fixtures/ data as part
of normal local-module work. Do not skip nf-tests just because the current repository has no
examples; use this skill's template and run nf-test to generate the snapshot. Omit tests only
when the user explicitly scopes the task to process code/config without tests, and say that tests
were intentionally omitted.
main.nf
Process name in UPPER_SNAKE_CASE matching the module directory name.
process MODULE_NAME {
tag "$meta.id"
label 'process_single' // choose: process_single, process_low, process_medium, process_high
conda "${moduleDir}/environment.yml"
container "${ workflow.containerEngine == 'singularity' && !task.ext.singularity_pull_docker_container ?
'<singularity_sha256_blob_url>' :
'<docker_image_url>' }"
input:
tuple val(meta), path(input_file) // prefer one tuple per logical group
val(threshold) // plain vals don't need a meta wrapper
output:
tuple val(meta), path("${prefix}.ext"), emit: result
// one emit per tool/library — no versions.yml heredoc needed
tuple val("${task.process}"), val('tool_name'), eval("tool --version 2>&1 | sed 's/tool //'"), emit: versions_tool, topic: versions
when:
task.ext.when == null || task.ext.when
script:
def args = task.ext.args ?: ''
def prefix = task.ext.prefix ?: "${meta.id}"
"""
script_name.py \\
--input ${input_file} \\
--output ${prefix}.ext \\
${args}
"""
stub:
def prefix = task.ext.prefix ?: "${meta.id}"
"""
touch ${prefix}.ext
"""
}
Input conventions
- Always include
val(meta)as the first element of every file-containing tuple. - Prefer a single tuple over multiple tuples when inputs logically belong together:
tuple val(meta), path(file1), path(file2)rather than two separate tuples. - Use separate tuples when inputs have independent identities (different samples, different meta maps).
- Plain scalars (
val) don't need a meta wrapper — justval(threshold).
Version output — topic channel, not versions.yml
Use the topic: versions convention. One emit line per tool or library:
// CLI tool
tuple val("${task.process}"), val('mytool'), eval("mytool --version 2>&1 | sed 's/mytool //'"), emit: versions_mytool, topic: versions
// Python package
tuple val("${task.process}"), val('biopython'), eval("python -c \"import importlib.metadata; print(importlib.metadata.version('biopython'))\""), emit: versions_biopython, topic: versions
// Python itself
tuple val("${task.process}"), val('python'), eval("python --version 2>&1 | sed 's/Python //'"), emit: versions_python, topic: versions
The sed pattern must reduce output to a bare version string (e.g. 1.85, 3.13.1).
Stub block
Just create empty outputs. No heredoc for versions — topic-channel emits work the same in stub:
stub:
def prefix = task.ext.prefix ?: "${meta.id}"
"""
touch ${prefix}.ext
"""
For directories: mkdir -p dir && touch dir/placeholder
For compressed files: echo "" | gzip > ${prefix}.gz
Script section: default to bin/ scripts
Use the module script: block as a thin command wrapper. Simple one-line Bash commands, shell
builtins, pipelines, or direct tool invocations may stay inline. If the logic is custom Bash,
Python, R, or more than a trivial one-line CLI invocation, put that logic in an executable script
under the pipeline's top-level bin/ directory and call it from the module.
Inline script bodies are acceptable only when:
- the command is a simple one-line Bash/tool invocation such as
mkdir -p outorsamtools sort ..., or - the user explicitly asks to keep the full Bash/Python body inside the module for teaching or prototyping.
Do not write Python heredocs or multi-line Bash programs directly inside local module script:
blocks by default. Nextflow adds bin/ to PATH, so scripts are called by name:
script:
def args = task.ext.args ?: ''
def prefix = task.ext.prefix ?: "${meta.id}"
"""
my_script.py \\
--input ${input_file} \\
--output ${prefix}.tsv \\
${args}
"""
Rules for bin/ scripts:
- Executable:
chmod +x bin/my_script.py - Shebang on line 1:
#!/usr/bin/env python3/#!/usr/bin/env Rscript/#!/usr/bin/env bash - Dependencies declared in
environment.ymland the container. - Name after what it does, not after the module — one script may serve multiple modules.
- Keep command-line arguments explicit (
--input,--output, thresholds, modes) so the module interface remains clear and easy to test.
Container selection
For Python-based local scripts, prefer Seqera Wave containers:
- Docker:
community.wave.seqera.io/library/<pkg1>_<pkg2>:<hash> - Singularity:
https://community-cr-prod.seqera.io/docker/registry/v2/blobs/sha256/<hash>/data
Look up the Wave container for your dependency combination at https://seqera.io/wave/ or check
existing local modules for a matching image. For non-Python tools, use the standard
quay.io/biocontainers/ or depot.galaxyproject.org/singularity/ images.
meta.yml
Topic-channel modules use versions_<tool>: output sections and a topics: block instead of the
old versions.yml output entry.
---
# yaml-language-server: $schema=https://raw.githubusercontent.com/nf-core/modules/master/modules/meta-schema.json
name: "module_name"
description: One sentence describing what the module does.
keywords:
- keyword1
- keyword2
tools:
- "module_name":
description: "Same as the top-level description."
homepage: "<pipeline_repo_url>/tree/dev/modules/local/module_name/"
documentation: "<pipeline_repo_url>/tree/dev/modules/local/module_name/"
tool_dev_url: "<pipeline_repo_url>/tree/dev/modules/local/module_name/"
doi: "<pipeline_doi_if_applicable>"
licence: ["MIT"]
identifier: ""
input:
- - meta:
type: map
description: |
Groovy Map containing sample information
e.g. `[ id:'sample1' ]`
- input_file:
type: file
description: Description of the input file.
pattern: "*.{ext1,ext2}"
- - threshold:
type: float
description: Description of the threshold parameter.
output:
result:
- - meta:
type: map
description: |
Groovy Map containing sample information
e.g. `[ id:'sample1' ]`
- "*.ext":
type: file
description: Description of the output file.
pattern: "*.ext"
versions_tool_name:
- - ${task.process}:
type: string
description: The process the versions were collected from
- tool_name:
type: string
description: The tool name
- tool --version 2>&1 | sed 's/tool //':
type: eval
description: The expression to obtain the version of the tool
topics:
versions:
# Repeat the corresponding versions_<tool> entries here.
authors:
- "@vagkaratzas"
maintainers:
- "@vagkaratzas"
Notes:
- Each input/output corresponds to one list item (the
- -double-dash). - Output channel names match the
emit:names frommain.nf. - For multiple versioned tools, add one
versions_<tool>:block per tool inoutput:, and one entry per tool intopics: versions:.
environment.yml
---
# yaml-language-server: $schema=https://raw.githubusercontent.com/nf-core/modules/master/modules/environment-schema.json
channels:
- conda-forge
- bioconda
dependencies:
- python=3.13.1
- conda-forge::biopython=1.85
Pin exact versions. Use the channel prefix (conda-forge::) only when the package doesn't come
from the default channel order.
tests/main.nf.test
Always write at least two tests for each local module: one real run and one stub run. Treat tests and fixtures as required deliverables for complete local-module work, not a follow-up, unless the user explicitly says to skip tests.
nextflow_process {
name "Test Process MODULE_NAME"
script "../main.nf"
process "MODULE_NAME"
test("<dataset> - <input_type> - <output_type>") {
when {
process {
"""
input[0] = channel.of([
[ id: 'test' ],
file("${moduleDir}/tests/fixtures/input.ext", checkIfExists: true)
])
input[1] = 0.9
"""
}
}
then {
assertAll(
{ assert process.success },
{ assert snapshot(process.out).match() }
)
}
}
test("<dataset> - <input_type> - <output_type> - stub") {
options "-stub"
// identical when{} block as the real test above
then {
assertAll(
{ assert process.success },
{ assert snapshot(process.out).match() }
)
}
}
}
Test naming
- Follow the nf-core pattern:
"<dataset> - <input_type(s)> - <output_type>". Examples:"sarscov2 - fasta - alignment","homo_sapiens - bam bai - vcf". - Stub tests append
" - stub"to the same name. - Use a descriptive dataset name (e.g.,
sarscov2,homo_sapiens, or a short content descriptor likesmall_fasta).
Fixtures path
${moduleDir} in nf-test process tests resolves to the directory containing main.nf
(i.e. modules/local/<name>/). Use it to reference local fixtures.
Assertion priority
Every snapshot must be inside assertAll() as snapshot(...).match(). Choose the strategy based
on output stability — try option 1 first and only downgrade when it proves flaky:
1. Full snapshot (default — always try first):
{ assert snapshot(process.out).match() }
2. Per-channel + line count — when content varies but file length is stable:
{ assert snapshot(
process.out.stable_channel,
path(process.out.unstable_channel[0][1]).readLines().size(),
process.out.findAll { key, val -> key.startsWith("versions") }
).match() }
3. Per-channel + line content match — when length also varies but a specific line is guaranteed:
{ assert snapshot(
process.out.stable_channel,
path(process.out.unstable_channel[0][1]).readLines().contains("<expected line>"),
process.out.findAll { key, val -> key.startsWith("versions") }
).match() }
The .contains(...) returns a boolean that snapshots cleanly. Only use lines guaranteed by the
tool's output spec, never incidental content.
4. Existence only (last resort):
{ assert snapshot(
process.out.stable_channel,
path(process.out.unstable_channel[0][1]).exists(),
process.out.findAll { key, val -> key.startsWith("versions") }
).match() }
Rules:
- Stub tests always use option 1 (
process.out) regardless of real test strategy. - When using options 2–4, assert versions with
process.out.findAll { key, val -> key.startsWith("versions") }— neverpath(process.out.versions[0]).yamlor any other form. - Never snapshot empty-file md5sums (
d41d8cd98f00b204e9800998ecf8427e). If a channel captures an empty file, the tool produced no real output — fix the test data or exclude that channel.
Environment setup
Ask the user which profile they use, then apply:
| Profile | Requirement |
|---|---|
| singularity | Ask for NXF_SINGULARITY_CACHEDIR — do not assume a default; export NXF_SINGULARITY_CACHEDIR="<user path>" |
| docker | Docker daemon running — no extra env var |
| conda | conda on PATH — no extra env var |
Test commands
CRITICAL — always prefix the profile with
+(e.g.+singularity,+docker,+conda). The+appends the container profile on top of the basetestprofile. Omitting it replaces the base profile entirely, breaking nf-core test infrastructure.
# Run all tests
nf-test test /path/to/main.nf.test --profile +singularity --verbose
# Run a single test by name (faster iteration)
nf-test test /path/to/main.nf.test --profile +singularity --verbose --tag "<test_name>"
# Generate or update snapshot
nf-test test /path/to/main.nf.test --profile +singularity --verbose --update-snapshot
Replace +singularity with +docker or +conda as appropriate.
tests/fixtures/
Create the smallest real data file that exercises the module logic:
- FASTA: 3–5 short sequences (< 100 bp/aa each)
- TSV/CSV: 5–10 rows covering the range of values the module processes
- Compressed files: gzip the minimal file above
Place files directly in tests/fixtures/ and commit them alongside the module.
If a module has no natural file input, create the smallest fixture needed by downstream assertions
or use inline scalar inputs in main.nf.test; still create a real and stub nf-test for the process.
Checklist before committing
- Process name is
UPPER_SNAKE_CASEand matches directory name - Every input tuple starts with
val(meta) - Version outputs use
topic: versions— noversions.ymlheredoc in script or stub -
meta.ymlhasversions_<tool>:output sections and atopics: versions:block (noversions.ymlentry) - Stub block only touches/creates output files — no script logic
- Simple one-line Bash/tool commands may stay inline; substantial custom Bash/Python/R logic
lives in top-level
bin/, unless the user explicitly requested inline module script bodies -
environment.ymlhas pinned versions - If the module calls a
bin/script: file exists, has a shebang, and is executable (chmod +x) -
tests/fixtures/contains minimal real data - Every local module has
tests/main.nf.testwith one real run and one stub run, unless the user explicitly scoped tests out - Snapshot strategy chosen per assertion priority (option 1 tried first); stubs always use
process.out - No empty-file md5sums (
d41d8cd98f00b204e9800998ecf8427e) in snapshots - Snapshot generated with
--profile +<profile> --update-snapshot -
nextflow lintpasses (notnf-core lint)