Plugins
A plugin is the last, smallest thing that actually executes.
Every task in this extension bottoms out in one — a bare echo
one-liner, a workflow task, an SDD phase. There is no second execution
path underneath.
A plugin never runs on its own. A task
uses it, via uses:, and supplies its args: — the task is the wrapper,
the plugin is the thing that executes.
Keys and defaults only: the cheat sheet.
- Everything desugars to a plugin
uses: id@majorargs:is the boundary- The round trip
- The four that ship
- Where they come from
- Naming one once per file
- What a plugin gives back
- Checked before anything runs
Everything desugars to a plugin
A task declares what it runs in one of two ways, and they are the same thing:
setup: echo Restoring... # a bare string, which means...
setup:
run: echo Restoring... # ...this, which means...
setup:
uses: shell@1 # ...this.
args:
script: echo Restoring...
run: is author sugar. The parser rewrites it to shell@1 with the
command as script:, so even the tersest task is a plugin call — which
is why a run: task’s stdout and exit code can be captured with
output: exactly like any other plugin’s.
Declaring both run: and uses: is an error: “a task runs one thing.”
uses: id@major
Always an id and a major version. Both halves are required:
uses: pwsh@1 # ok
uses: pwsh # error - a missing version is never implied
uses: actions/setup-node@v4 # error - not a marketplace action
Ids are lowercase kebab (^[a-z0-9]+(-[a-z0-9]+)*$), versions are whole
numbers, and there is no id@0.
Only the major is pinned. Minor and patch float underneath it, so a fix reaches every workflow without anyone editing a file — and a breaking change has to announce itself by taking a new major, which is a new folder and a new reference.
A bare id is rejected rather than defaulting to 1: an implicit version
means a workflow’s meaning can change under it when a plugin ships a v2,
which is exactly the drift a committed file is supposed to prevent.
args: is the boundary
Engine keys sit at the task level. Everything the plugin itself consumes
goes inside args:.
- name: Say hello # engine
uses: greet@1 # engine
args: # <- the plugin's, whatever it declares
name: World
output: GREETING # engine
That split is not cosmetic. Two things break without it:
- The same word legitimately means two things. An
http@1task wants atimeoutfor the request; the engine wants one for the task. Flat, only one can exist. - The parser could never reject an unknown key, because unknown keys
would be the plugin’s args — so a misspelled
triger: manualwould silently become an arg and the human gate would disappear.
With args:, the task level is a closed set and that typo is a hard
error.
The round trip
The keys you write under args: are not free-form. They are exactly the
names the plugin declares, and they arrive in its code under those
same names. greet@1 — which ships in
Samples/HelloWorld/.local-workflows/plugins/greetV1/ — end to end:
1. The plugin declares what it accepts, in plugin.json:
{
"id": "greet",
"version": { "major": 1, "minor": 0, "patch": 0 },
"args": [
{ "name": "name", "type": "string", "required": true },
{ "name": "excited", "type": "boolean", "default": false }
],
"outputs": ["greeting"]
}
2. Your task supplies them, by those names, under args::
tasks:
hello:
uses: greet@1
args:
name: World # <- declared above, and required
excited: true # <- declared above; omit it and the default is false
output: GREETING
3. The plugin reads them, already resolved and type-checked:
async execute(args, ctx) {
const greeting = `Hello, ${args.name}${args.excited ? "!" : "."}`;
return { success: true, outputs: { greeting } };
}
4. A later task reads what came back:
announce:
needs: hello
run: echo ${{ run.GREETING }}
greet@1 declares exactly one output, so
${{ run.GREETING }} is the greeting. Two or more
and you name the key — ${{ run.GREETING.greeting }}.
Three names, one list. Misspell naem: in step 2 and the run does not
start: the parser knows what greet@1 accepts without executing any of
it. See checked before anything runs.
The four that ship
shell@1 |
a command in the OS default shell — what run: becomes |
pwsh@1 |
PowerShell 7+ |
file@1 |
file operations |
ai@1 |
an agent that works in the repo, on the provider you name |
ai@1 is the only one that needs anything, and what it needs depends on
its provider: — ghcp, the one that ships, needs a Copilot sign-in.
Every arg each of them accepts is on built-in plugins.
Where they come from
| Origin | Location |
|---|---|
| bundled | compiled into the extension |
| folder | <repo>/.local-workflows/plugins/ |
| workspace | beside an open .code-workspace |
| profile | ~/.local-workflows/plugins/ |
Every open folder contributes its own, not just one of them. The
workspace origin exists only when you opened a .code-workspace — see
Workspaces.
Nothing is downloaded and nothing is signed.
Bundled plugins register first, so one you write can never silently take
over a bundled id — that collision is reported as a load failure. A
plugin from a wider scope standing aside for a narrower one of the
same id is the exception, and the whole point of having scopes:
folder beats workspace beats profile, so a repository that ships its
own deploy@1 means its deploy.
Two different repositories declaring one id is still reported. Neither is narrower, so there is no right answer to pick.
Plugins you write run sandboxed; bundled ones do not. See writing a plugin for both the reasoning and how to write one.
Naming one once per file
A plugins: map gives a reference plus default args a local name:
plugins:
announce:
uses: pwsh@1
args:
script: Write-Output 'Announcing...'
tasks:
notify:
uses: announce # the alias's defaults
notify-loud:
uses: announce
args:
script: Write-Output 'LOUDLY!' # the task's arg wins
An alias may not point at another alias. The ai: block in a workflow is
this same mechanism under its oldest name, and accepts only uses: and
args:.
What a plugin gives back
output: NAME stores the plugin’s outputs in a run variable, readable
anywhere later as ${{ run.NAME.key }}. When a
plugin declares exactly one output, the variable is that value.
Run variables are flat and run-scoped. They cross job and stage
boundaries for free, which is why there is no job-level outputs: block
and no ${{ jobs.x.outputs.y }} — a whole layer of
plumbing deleted.
A task with no output: exports nothing. What a task exports is
declared, never implicit.
Checked before anything runs
Every uses: in a file is validated against the plugin registry before a
single command executes: an unregistered reference, a missing required
arg, an arg the plugin does not accept, a literal of the wrong type,
a ${{ vars.X }} declared nowhere, and a
${{ run.X.key }} naming an output the producing
task never returns.
Whole ${{ }} expressions are left alone — their
type is not knowable until the run resolves them — as is env, which
comes from the machine.
That check is possible because a plugin’s declaration lives in a manifest separate from its implementation. The engine knows what a plugin accepts without executing a line of it.