| name | udt-authoring |
| description | Use when authoring a Galaxy User-Defined Tool (UDT) -- a `class: GalaxyUserTool` YAML definition that wraps a container and command into a tool a non-admin user creates and runs (e.g. via Galaxy MCP create_user_tool / run_user_tool, or POST /api/unprivileged_tools). Not for classic XML/ToolShed tool wrappers. |
| metadata | {"surfaces":["loom"]} |
| version | 1.0.0 |
| tags | ["galaxy","tools","udt","user-defined-tools","custom-tool"] |
Authoring Galaxy User-Defined Tools (UDTs)
A User-Defined Tool (UDT) is a tool that a regular (non-admin) Galaxy user defines in YAML
and runs unprivileged, introduced in Galaxy 25.0. It is declared with class: GalaxyUserTool and
validated against the UserToolSource schema. This skill is the reference for producing a correct,
lint-clean definition.
When to Use
- The user wants to wrap a command + container as a runnable Galaxy tool they own.
- Triggers: "make me a Galaxy tool that runs X", "wrap this command as a UDT", "create a custom
tool", "register a tool in my account".
- You are an external agent (Loom, an MCP client, a notebook) that will create/run the tool via
create_user_tool / run_user_tool or POST /api/unprivileged_tools.
When NOT to use: classic XML / ToolShed tool wrappers (Cheetah templating, tools-iuc
submission) -- that is a different format; use the tool-dev skill instead. A UDT is not XML
and does not use Cheetah.
Mental Model (read this first -- it is where agents go wrong)
A UDT = a container + a shell_command + typed inputs and outputs.
- The command lives under
shell_command (NOT command).
- Parameters are interpolated with sandboxed ECMAScript inside
$(...) -- NOT Cheetah $x and
NOT #for# loops. A data file's path is $(inputs.name.path); a scalar value is
$(inputs.name).
- The
$(...) templating sandbox has no database or filesystem access. Every UDT must run in
a container (container: is a required plain string); whether that container has network access
is set by the Galaxy admin's job config, not by the tool.
- Every dataset/collection output must claim a file it produced via
from_work_dir (one
file) or discover_datasets (many) -- you do not write to a templated output path.
- Scalar
text/select values interpolate into shell_command unquoted (unlike base_command
args, which are shlex.quoted) -- a value with spaces or quotes breaks the command. Quote them,
or pass free text via a configfile. See references/templating.md.
If you find yourself writing command:, $param, #for#, truevalue:, or ${on_string}, stop
-- those are XML/Cheetah habits the schema rejects. See references/common-mistakes.md.
Quick Reference -- minimal valid skeleton
class: GalaxyUserTool
id: my-tool
version: "0.1.0"
name: My Tool
description: One line shown in the tool menu
container: quay.io/biocontainers/seqkit:2.8.2--h9ee0642_0
shell_command: |
seqkit stats --tabular '$(inputs.input.path)' > stats.tsv
inputs:
- name: input
type: data
format: fastq
label: Input sequences
outputs:
- name: report
type: data
format: tabular
from_work_dir: stats.tsv
label: Sequence statistics
Full field list, input/output types, and validator rules: references/schema-reference.md.
Templating syntax ($(inputs.x), arrays, $GALAXY_SLOTS, escaping): references/templating.md.
Authoring Workflow
- Understand the command. What binary runs, what inputs it consumes, what files it produces.
- Pick a real container -- this is the #1 cause of runtime failure. Prefer a verified
biocontainer (
quay.io/biocontainers/<tool>:<tag>), but never guess the --<hash>_<build>
tag suffix -- it is not predictable. Look up the real tag, or, when the tool needs only the
Python standard library, use an official python:3.x-slim image and write the logic inline.
Do not invent an image or flag you cannot verify -- ask instead. See "Choosing a container"
below. Lint will not catch a bad image; the job simply fails to pull it.
- Draft the definition using
references/schema-reference.md. Reference each input as
$(inputs.name.path) (data) or $(inputs.name) (scalar). Write outputs to fixed filenames and
claim them with from_work_dir / discover_datasets. Always include a help block (convention
-- see "Write a help block" below).
- Self-review against
references/common-mistakes.md (it doubles as a checklist).
- Validate (see below) and iterate on real errors.
- Create + run. Hand the validated definition to
create_user_tool, then run_user_tool
(this skill stops at a valid definition -- the run/invocation loop is the caller's job).
Choosing a Container
The container is the part most likely to pass validation but fail at run time, because nothing in
the schema or lint checks that the image actually exists or pulls. A real example: a plotting UDT
that declared quay.io/biocontainers/seaborn:0.13.2--pyhd8ed1ab_3 was accepted by
create_user_tool, then the job died with manifest unknown -- that exact build tag did not exist
on quay. Guard against it:
- Don't guess the biocontainers build suffix (
--<hash>_<build>). It is not derivable from the
version. Look up the real tag: browse https://quay.io/repository/biocontainers/<tool>?tab=tags
or query https://quay.io/api/v1/repository/biocontainers/<tool>/tag/?onlyActiveTags=true.
- For a stdlib-only tool, skip biocontainers entirely. Use an official base image like
python:3.11-slim (or busybox for shell-only) and write the logic inline (see the heredoc
pattern in references/templating.md). The seaborn failure above was fixed exactly this way:
switch to python:3.11-slim and emit SVG with the standard library, no third-party image to get
wrong.
- Never invent an image, tag, or CLI flag you can't verify -- ask the user rather than guessing.
Write a help block (convention)
help is schema-optional, but always include a real one. Galaxy renders it under the parameter
form on the tool page, so a UDT without help is a black box to anyone -- including the same user
weeks later -- who opens it. The wire shape is an object, not a bare string:
help:
format: markdown
content: |
Cuts a single column from a tab-separated file.
**Inputs**
- `input`: TSV file (with or without header).
- `column`: 1-based column index.
**Outputs**
- `output`: TSV with only the selected column, in input row order.
Header lines are not auto-detected; the column index applies to every row.
Three short paragraphs is enough: what the tool does, its inputs/outputs, and any caveat (resource
limits, expected file shape, non-obvious behavior). Don't ship a TODO placeholder -- an empty or
stub help is no better than none. If help genuinely isn't worth writing (a one-off throwaway), say
so and confirm with the user before creating the tool.
Validating a Definition
Validation logic is pure Python in galaxy-tool-util -- the same code the server runs -- so you can
check offline before submitting. Three tiers, weakest to strongest:
- Local (fast, offline, side-effect-free):
python scripts/validate.py my-tool.yml. Needs
pip install galaxy-tool-util. Catches structural errors, the four semantic validators, and
lint warnings. Use this whenever a Python env is available (terminal, CI). See scripts/.
planemo lint -- not available yet (planemo does not lint UDTs as of this writing). It
already depends on galaxy-tool-util, so teaching it to lint a GalaxyUserTool YAML would be a
small addition, and it's the natural future home for tier 1. Down the road scripts/validate.py
should migrate to planemo lint <tool>.yml, so authors get this check through the standard Galaxy
tool CLI instead of a bundled script. Until that lands, use tier 1.
- Server create --
create_user_tool / POST /api/unprivileged_tools runs the same lint and
confirms this server accepts it (needs enable_beta_tool_formats + the Custom Tool Execution
role). This is the final word, and the practical gate for environments without Python (e.g.
Loom): submit, then iterate on the returned errors.
Lint-clean is necessary but not sufficient. No check here -- not validate.py, not the server's
create lint -- verifies that the container image actually pulls or that the command succeeds.
Those only surface when the job runs. A nonexistent container tag (manifest unknown at run
time) is the most common real-world UDT failure, so verify the image (see "Choosing a Container")
before relying on a clean validation. It also can't see runtime JS-expression errors, shell
quoting, the server's role/config gates, or whether the command actually produces the declared
outputs -- only running the tool does.
Iterating & Cleanup
There is no in-place update API -- every create_user_tool call makes a new tool. When you
iterate, bump version (e.g. 0.1.0 -> 0.2.0); Galaxy keeps every prior version under the user's
account. Heavy iteration accumulates stale versions, so once a design stabilizes, offer to deactivate
the superseded ones with delete_user_tool -- it marks the tool inactive (hidden from the toolbox),
not a hard delete; the records remain.
Limitations
UDTs deliberately can't reach several things classic tools can (per the Galaxy docs):
- No reference data -- no built-in genome indexes or
.loc files.
- No metadata files -- e.g. a BAM index (
.bai) isn't available; if the command needs one, build
it inside the shell_command or take it as an explicit data input.
- No
extra_files directory -- composite / extra-file outputs aren't supported.
Design around these: pass whatever the tool needs as an explicit input, and generate any side files
inside the container.
Common Patterns
| Goal | shell_command snippet |
|---|
| Single data file path | tool '$(inputs.reads.path)' |
| Scalar value | tool --count $(inputs.count) |
| Multiple data files | cat $(inputs.datasets.map(d => d.path).join(' ')) > out.txt |
| Boolean as a flag | tool $(inputs.verbose ? '--verbose' : '') |
| Use the job's allocated cores | tool --threads $GALAXY_SLOTS (+ a resource requirement, cores_min) |
| Capture one output file | declare output with from_work_dir: out.txt |
| Capture many outputs | declare output type: collection with discover_datasets |
| Run a non-trivial inline script | python <<'PY' ... PY heredoc; reference inputs as $(inputs.x) inside (see references/templating.md) |
See examples/ for seven complete, validated UDTs spanning these patterns.
Troubleshooting
| Symptom / error code | Cause | Fix |
|---|
dynamic_tool.output_unclaimed | Output has no from_work_dir/discover_datasets | Write to a fixed filename and claim it |
dynamic_tool.undeclared_input_ref | $(inputs.X) with no input named X | Declare the input or fix the name |
dynamic_tool.blank_container | container empty / missing | Set a real container image string |
string_pattern_mismatch on id | Uppercase, leading digit, spaces | Use ^[a-z][a-z0-9_-]*$ |
| extra-field rejection | XML-ism like truevalue, command, ${on_string} | Remove it -- schema is extra="forbid" |
manifest unknown / Unable to find image at run time | Container tag doesn't exist on the registry | Use a real, verified tag (or python:3.x-slim for stdlib tools) -- lint can't catch this |
| HTTP 403 "not allowed to run unprivileged" | The admin's job routing (TPV) rejects tool_type_user_defined by default | Not fixable client-side -- the admin must add a destination/routing rule for user-defined tools |
help rejected at create | help sent as a bare string | Use the object form: help: {format, content} |
Full list with the why behind each: references/common-mistakes.md.
Resources
references/schema-reference.md -- every UserToolSource field, input/output types, validators
references/templating.md -- the $(...) ECMAScript model, arrays, $GALAXY_SLOTS, escaping
references/common-mistakes.md -- pre-submit self-review checklist
scripts/validate.py -- offline validate + lint via galaxy-tool-util
examples/ -- seven complete UDTs, simple to complex (incl. an inline-script python:slim tool)
- Galaxy docs: User-Defined Tools
- For classic XML tools instead: the
tool-dev skill.