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.

  1. Everything desugars to a plugin
  2. uses: id@major
  3. args: is the boundary
  4. The round trip
  5. The four that ship
  6. Where they come from
  7. Naming one once per file
  8. What a plugin gives back
  9. 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@1 task wants a timeout for 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: manual would 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.


Table of contents


Back to top

Local Workflows is a VS Code extension. Everything it does is declared in a YAML file you own.