Repository navigation
Description
Updated after discussion with @mnriem: the proposal is now independently packaged Workflow Functions, used as pipe filters first. The earlier options A–C are kept below under "Alternatives Considered".
Author's scope update: bounded multi-argument filters are now included in this proposal. This expands the earlier zero/one-argument scope and is pending maintainer confirmation.
Problem Statement
The workflow expression language ships a fixed set of pipe filters: default, join, map, contains, split, from_json, upper, lower, length and to_json. These are defined in FILTER_TABLE in src/specify_cli/workflows/expressions/filters.py. There is no way for a project or a community package to add one. A missing filter, such as trim, forces authors into a shell step plus from_json, or into a custom step, for a one-line transformation.
Proposed Solution
Add Workflow Functions: independently packaged Python callables, installed per project and usable as pipe filters in expressions. Other uses (for example direct calls such as trim(x)) are added only when justified. This is not a new value type in the expression language.
Package layout
.specify/workflows/functions/text/
├── function.yml
└── __init__.py
id: text
functions:
- name: trim
handler: trim
min_args: 0
max_args: 0def trim(value: str) -> str:
return value.strip()Result: {{ steps.capture.output.stdout | trim }}.
Arguments: each function declares inclusive min_args / max_args bounds: non-negative integers, both defaulting to 0, with min_args <= max_args. Bounds count only the explicit arguments after the piped value; the handler is called as handler(value, *args). The bare form is allowed when min_args == 0. Parenthesized calls must contain at least one argument and satisfy the bounds; trim() is not an accepted zero-argument form. For example, a function declaring min_args: 2 and max_args: 2 can be used as {{ inputs.text | pad(12, '0') }}. Optional and bounded argument ranges are supported; unbounded variadic arguments remain out of scope.
Install: specify workflow function add <package>.
Why this fits the engine: the grammar already accepts any FILTER_NAME, so the parser needs no change. The dispatcher (_apply_filter) and the static validation (_check_filter / _evaluator_rejects) both read only FILTER_TABLE, so a registered function is validated and executed consistently.
Engine changes
- Add a project-scoped registry with registration, collision checks against built-in filters and other packages, a
BUILTIN_*snapshot, and a reset between projects (like_unload_custom_steps()). - Load the registry before
validate_workflowand before execution. Otherwise validation reports "unknown filter". - Compute
_EXPECTED_FILTERSdynamically. It is currently built once at import and would omit custom functions from error messages. - Wrap handler exceptions into
ValueError("filter '<name>': ..."). Built-ins only raiseValueError, and the engine does not catch anything else. - Replace
FilterSpec.argument/barewith the manifest'smin_args/max_argsbounds. Support zero, optional, single, and multiple explicit arguments through the same validation and evaluation path. Reuse the existing argument-list grammar; preserve built-in filter behavior. - Harden the loader as for steps (symlink guards, symlinked parent directories).
Alternatives Considered
- Filters inside step packages (earlier option B): reuses delivery, catalogs and the trust model, but a filter would have to live in a step package.
- A custom step per transformation (earlier option C): works today, but is verbose and not usable inline in conditions or interpolation.
- Declarative aliases in workflow YAML, such as
filters: {csv: "map('x') | join(',')"}: no Python and less trust risk, but cannot express new logic liketrim. - Direct calls (
trim(x)) and further hooks (input validators, gate verdicts): deferred. They need a grammar change and a policy for impure functions. - Adding the missing built-ins one by one: cheap, but never ends.
Use Cases
- Normalise captured shell output (
{{ steps.x.output.stdout | trim }}). Todayecho falseyields"false\n", which already needed a workaround inevaluate_condition. - Team-specific formatting (branch slugs, ticket IDs) in
commandandpromptinputs without extra steps. - Reusable function packages shared between projects.
Acceptance Criteria
- A registered function works as a filter in
{{ }}interpolation, in single-expression templates, and in conditions (if,while,switch). - Manifest argument bounds are validated. Bare, optional, single-argument, and multi-argument forms work within those bounds; validation and evaluation consistently reject too few/many arguments and empty parenthesized calls.
-
validate_workflowaccepts a registered function and reports an unknown one, with the custom function listed in the "expected one of ..." message. - A name collision with a built-in filter or another package is rejected, and the built-in is unchanged.
- A non-
ValueErrorexception from a handler surfaces asValueErrornaming the filter, not as a crash. - Functions from project A are not visible in project B in the same process.
-
specify workflow function add/remove/listmanage packages. - Positive and negative tests per
CONTRIBUTING.md#testing-deterministic-behavior, and docs indocs/workflows/expressions.mdanddesign/workflow-step.md. - Docs state the trust model (loading executes Python code) and the resume behaviour: completed steps are replayed from recorded results, while an unfinished step's expressions are evaluated again when it is retried (at-least-once). Handlers should therefore be deterministic and free of side effects.
Additional Context
Rough estimate: several days including the loader, installer and CLI, the function.yml schema, tests and docs. A catalog is not part of the first iteration.
AI Disclosure
Original draft: Pi Coding Agent (github-copilot provider), model claude-sonnet-5.5, reasoning effort high, human-supervised. Used for reading the code, drafting this issue text and revising it after maintainer feedback; reviewed by the author before posting.
Multi-argument scope update: authored and applied on behalf of @markuswondrak by Pi Coding Agent (github-copilot provider), model gpt-6-astra, reasoning effort xhigh, user-directed interactive mode. AI assistance covered the argument contract, example manifest, engine-change description, and acceptance criterion in this issue-body update.
Activity
mnriem commentedon Nov 6, 2026
What about option D - Workflow Functions that can be registered and in this case can be used as a filter, but maybe we can use them in other parts too? Thoughts? Pros? Cons? I realize it expands the scope, but I want to explore to see if this would lead to more similar asks?
markuswondrak commentedon Nov 6, 2026
No I like that - my first proposal was too narrow. General custom filters would be a mighty tool for workflow authors.
Would you introduce a new primitive type for those functions?
mnriem commentedon Nov 6, 2026
Yes—I’m thinking of Workflow Functions as independently packaged Python callables. For example, a user would install a package with a proposed command such as
specify workflow function add <package>, placing it under:The manifest would expose
trim:And
__init__.pywould implement it:Spec Kit would load these declarations into a project-scoped registry before validation and execution, making
{{ steps.capture.output.stdout | trim }}available. Essentially, this is option A generalized to Workflow Functions: filters first, other uses only when justified—not a new primitive value type in expressions.AI assistance: GitHub Copilot (GPT-6.1 Sol), interactive mode; drafted this response at my request.
markuswondrak commentedon Nov 6, 2026
I like that! I will update the issue accordingly.
[-][Feature]: Support custom filters in the workflow expression language[/-][+][Feature]: Workflow Functions: custom filters for the workflow expression language[/+]markuswondrak commentedon Nov 6, 2026
I updated the issue description to match the agreed direction: independently packaged Workflow Functions under
.specify/workflows/functions/<id>/, used as pipe filters first, withspecify workflow function add <package>. The earlier options are kept under "Alternatives Considered".I also corrected the resume note: completed steps are replayed from recorded results, and only an unfinished step's expressions are evaluated again on retry (at-least-once).
Open points for the manifest: the exact values for
argument(none/one/optional), and name-collision rules between packages and with built-ins. I'm happy to take the implementation.AI disclosure: posted on behalf of @markuswondrak; drafted with Pi Coding Agent (github-copilot), claude-sonnet-5.5, reasoning effort high, human-supervised; text reviewed by the contributor before posting.
mnriem commentedon Nov 6, 2026
Go for it!
fix(workflows): keep the zero-argument filter diagnostic in condition…
fix(workflows): reject case variants of reserved function ids