Tasks

Short commands you run all day: build, lint, reset the database. You keep them in one file, .local-workflows/tasks.yml, and you run them from the sidebar. This page is the whole feature — a five-minute build-up first, then every key and rule. Read it top to bottom to learn it, or jump to the reference to look something up.

A tasks.yml is a library of commands, each runnable on its own. The other format, one pipeline run as a whole, is Workflows.

  1. 1. Create the file
  2. 2. Run it
  3. 3. Add a second task, and an order
  4. 4. Branch it
  5. 5. Know which shell you are in
  6. 6. Add values
  7. 7. Break it on purpose
  8. Every task runs a plugin
  9. Reference: tasks.yml
    1. Top-level keys
    2. A task
    3. Complete example
  10. Values and templating
    1. Directory anchors
    2. Typed values
    3. if: grammar
    4. run.context
    5. File inputs
  11. Naming a plugin once
  12. The list in the sidebar
  13. Errors and silent failures

1. Create the file

Local Workflows reads one folder: .local-workflows/, next to your code. Create a file in it called tasks.yml:

version: 1

tasks:
  hello: echo Hello World

That is a complete, valid pipeline. tasks: is a map keyed by task id, and a bare string is shorthand for run: — so hello is a task that runs one command.

You can skip the typing: open the Local Workflows view with no workflow files present and its empty state offers to create exactly this, as does the Local Workflows: Initialize tasks.yml command.

version: 1 is optional in a tasks.yml and worth writing anyway. It is what lets a future build migrate your file instead of guessing at its shape.


2. Run it

Click the Local Workflows icon in the Activity Bar. Your file appears, expandable into its tasks.

Click the hello row. Nothing runs. Clicking only ever opens — the panel shows you what would run, drawn as a graph, before you commit to it. Starting a run is always the explicit ▶ icon on the row, or the Run button in the panel.

Hit ▶. The run panel streams the output, one row per command, with the task turning green when it exits 0.

A task can be a database reset or a destructive cleanup, so an accidental click must never start one. That is why clicking opens and only ▶ runs. The Tasks row above your tasks has no ▶ at all — there is no “run the whole file”, by design.


3. Add a second task, and an order

One task is not a pipeline. Add a second, and declare that it depends on the first:

version: 1

tasks:

  setup: echo Restoring dependencies...

  build:
    name: Build
    desc: Compiles the app
    needs: setup
    run: echo Compiling...

needs: is the whole ordering model. It takes one id or a list of them.

Now run build on its own. setup runs first — running any task pulls in its full transitive needs: chain, and only that subset. Tasks are composable and independently runnable; there is no separate “job” concept to learn, and nothing in the file runs on its own until you ask for it.

name: is the label in the tree, desc: is its tooltip. Both optional; without name:, the id is shown.


4. Branch it

Dependencies form a graph, not a chain. Two tasks that both needs: setup are declaring they do not depend on each other — so they run at the same time:

version: 1

tasks:

  setup: echo Restoring dependencies...

  build:
    name: Build
    needs: setup
    run: echo Compiling...

  lint:
    name: Lint
    needs: setup
    run: echo Linting...

  test:
    name: Test
    needs: [build, lint]
    run: echo Running tests...

Run test. The panel draws the diamond — setup, then build and lint side by side, then test — and colours each node as it finishes. Click any node to open its log in the side panel.

A run that quietly serialised build and lint would be making the file’s declaration a lie, so it does not.


5. Know which shell you are in

A task with no shell: runs through whatever the OS already provides — cmd on Windows, bash elsewhere. Never pwsh by default.

So the moment you write PowerShell syntax — $env:NAME, Get-Location, a .ps1 invocation — say so:

  build:
    name: Build
    needs: setup
    shell: pwsh
    run: echo "Compiling in $env:BUILD_CONFIGURATION configuration..."

This is the single most common first-run failure: a $env: reference that silently comes back empty because the task ran in cmd.


6. Add values

Two blocks, and they are deliberately not one:

  • vars: — values to substitute into this file. Never exported to anything you spawn. Typed: a number stays a number.
  • env: — the environment a process is spawned with. Exported. Strings only.

Put a value in vars: when you want to use it, and in env: only when something you run needs to read it. Both can be set at the file level and overridden per task.

Substitution is ${{ }}, and four directory anchors are always available: ${{ workspaceDir }} (where the editor opened), ${{ workspaceHome }} (that folder’s own .local-workflows), ${{ cwd }} (where this command actually runs), and ${{ home }} (your home directory, for files that are yours rather than the project’s).

The set is closed. A name outside it - ${{ root }}, say - fails with the list of what is available rather than resolving to somewhere on the machine.

version: 1

vars:
  platform: linux-x64
  artifact: myapp-${{ vars.platform }}    # a var may read a sibling var

env:
  BUILD_CONFIGURATION: Release
  ARTIFACT_NAME: ${{ vars.artifact }}     # env: may read vars

tasks:

  setup: echo Restoring dependencies...

  build:
    name: Build
    needs: setup
    shell: pwsh
    env:
      BUILD_CONFIGURATION: Debug          # overrides the file-level value
    run: echo "Compiling $env:ARTIFACT_NAME in $env:BUILD_CONFIGURATION..."

  package:
    name: Package
    needs: build
    shell: pwsh
    cwd: ${{ cwd }}/dist
    run: echo "Packaging from $(Get-Location)..."

Resolution runs one way only, so nothing can chase its own tail: anchors, then vars:, then env:, then cwd: and run:. vars: cannot read env: — vars are the file’s own constants, env is machine-dependent, and letting constants depend on the environment inverts which of the two is stable.

You rarely need an anchor for your own files. A task in a .local-workflows/ folder starts in the project that folder belongs to - not in the folder the file sits in. So scripts/build.ps1 means the same thing from tasks.yml and from workflows/deploy.yml, and packages/api/.local-workflows/tasks.yml starts its tasks in packages/api. No ../, and no anchor.


7. Break it on purpose

Add a task that fails, and run it:

  error-demo:
    name: Error Demo
    shell: pwsh
    run: |
      echo 'About to fail on purpose...'
      exit 1

The node turns red, the error surfaces in the panel, and anything that needs: it is marked Skipped rather than run. A failed prerequisite stops its dependents; it does not stop unrelated branches.

Try a typo, too — nees: setup. It fails before anything executes, naming the unknown key. The same is true of an unresolvable ${{ }}: it is caught by pre-run validation, not discovered halfway through a build.

That is the whole loop: edit tasks.yml → click a task → hit ▶ → watch it run locally, instantly. Everything below is the reference for what a task can carry.

The repository’s Samples/HelloWorld/.local-workflows/tasks.yml exercises every feature in one file, with a comment on each explaining what it is there to demonstrate.


Every task runs a plugin

A task declares what it runs in one of two ways, and they are the same thing: a plain string (or run:) is sugar for the shell@1 plugin with the command as its script:. uses: names any other plugin, and args: is what that plugin consumes. There is no second execution path underneath — which is why even a run: task’s stdout and exit code can be captured with output: like any other plugin’s.

run: and uses: on one task is an error: a task runs one thing.


Reference: tasks.yml

Lives at .local-workflows/tasks.yml. The top-level key must be tasks: — a file keyed on anything else parses into no tasks, with no error.

Top-level keys

Key Type Default Meaning
version 1 1 when absent Schema version. Optional here; a wrong value is an error.
name string   Never shown anywhere. Leave it out.
vars map   See Values and templating.
env map   See Values and templating.
dotenv string | string[]   See Values and templating.
plugins map   File-local plugin aliases — see Naming a plugin once.
tasks map   The tasks, keyed by id. The id is what needs: names, and the shown name when a task has no name:.

A task

A plain string value is shorthand for run: in the OS default shell. The task level is a closed set — an unknown key is a hard error. run: and uses: are mutually exclusive. A task with neither is legal: it succeeds and logs Task has nothing to run.

Key Type Default Meaning
name string the task’s id Shown in the panel and tree.
desc string   One line, shown as the tooltip.
needs string | string[]   Task ids that must finish first. The only ordering a tasks.yml has. Running a task runs its transitive needs: chain first.
run string   A command line (block scalar for multiline). Desugars to shell@1.
uses string   id@version, or a bare name declared in plugins:.
args map   Everything the plugin consumes. Plugin inputs never sit at task level.
shell pwsh | cmd | bash OS default OS default is cmd on Windows, bash elsewhere. pwsh is never the default and must be named.
session string   Names an AI conversation. Tasks sharing the name share one thread, in order. Run-scoped.
trigger auto | manual auto manual stops the run until a human approves in the run panel. Survives closing the editor.
output string   Stores the task’s outputs as run variable NAME. Read later as ${{ run.context.NAME.key }}; a plugin with exactly one output reads as ${{ run.context.NAME }}.
if string | boolean | number   Runs the task only when true. False is a skip, not a failure. See if: grammar.
continueOnError boolean false Run carries on past this task’s failure. Task still reports Failed. Does not excuse a rejected gate or a duplicate output: name.
timeout number | string none Bare number = seconds. 500ms, 30s, 5m, 1h accepted. A timeout counts as a retryable failure.
retries integer 0 Extra attempts: retries: 1 = two runs total.
cwd string file’s project folder Relative to the file’s own folder unless absolute.
vars / env / dotenv     Per-task overrides, merged over the file’s.

There is no whole-file run — a tasks.yml is a library, not a pipeline. For one runnable entry that covers the file, write a task whose needs: names what it should cover.

Complete example

version: 1

vars:
  config: Release

tasks:
  setup: npm ci                        # bare string = run: in the OS default shell

  build:
    needs: setup
    shell: pwsh
    run: ./build.ps1 -Configuration ${{ vars.config }}

  draft-notes:
    uses: ai@1
    args:
      prompt: Summarise the commits since the last tag.
    output: NOTES

  all:                                 # pure composition node - runs its needs: chain
    needs: [build, draft-notes]

Values and templating

Shared by both file formats — everything here means the same thing in a workflow. Declarable at file, job (workflows only) and task level; nearer wins, merged per key.

Block Exported to spawned processes? Typed? Meaning
vars no yes — a whole-value ${{ vars.x }} keeps its YAML type Template values for this file only. May read anchors and sibling vars, never env:.
env yes no — coerced to text The process environment. May read anchors and vars:.
dotenv yes no One .env path or a list, resolved relative to the file, layered under env:. Later file wins; declared env: wins over both. A missing file is skipped with a log line, never an error.

Resolution order (one-directional, no cycles):

  1. Directory anchors — workspaceDir, workspaceHome, cwd, home
  2. vars: — may read anchors and each other
  3. dotenv: files, then env: — may read anchors and vars
  4. cwd:, run:, task args: — may read everything plus run.context.*

Directory anchors

The set is closed; any other name is an error listing what exists.

Anchor Resolves to
${{ workspaceDir }} where the editor opened
${{ workspaceHome }} workspaceDir’s own .local-workflows/
${{ cwd }} where this task runs; inside a cwd: expression it means the inherited value, so cwd: ${{ cwd }}/dist works
${{ home }} the user’s home directory; constant for the whole run

A file inside .local-workflows/ starts its tasks in the folder holding .local-workflows/, however deep it sits. A profile-level definition starts in the open workspace.

Typed values

A value that is nothing but one ${{ }} expression keeps its type; inside a larger string it is stringified. retries: ${{ vars.n }} hands over a number; tag: v${{ vars.n }} a string.

if: grammar

Resolved first, then evaluated as text. A value alone is true unless empty, false or 0. == and != compare two sides; a leading ! negates; quotes optional. No &&, no functions. A condition that cannot be resolved fails the task; a false one skips it. A skipped task never opens its gate.

run.context

output: NOTES on one task → ${{ run.context.NOTES }} or ${{ run.context.NOTES.key }} in a later one — in a workflow, across stage barriers included. The context segment is mandatory. Two tasks declaring the same output: name: validation warns, and if both run, the second to write fails.

File inputs

Any built-in plugin arg also accepts a whole-value ${{ }} expression or a file input:

args:
  prompt: { file: prompts/draft.md }          # required; missing file fails, naming the path
  notes:  { file: notes.md, optional: true }  # missing resolves to ""

Naming a plugin once

A plugins: map gives a uses: 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 entry accepts only uses: and args:; the task’s own args: win per key. An alias may not point at another alias, and an entry name must not contain @ — a bare name is what tells an alias apart from a real id@version where it is used. Two entries may name the same plugin with different defaults, which is how one file runs ai@1 on two providers.


The list in the sidebar

Tasks are listed in the order you wrote them. The Tasks heading carries one icon — Sort Tasks A-Z / Sort Tasks in File Order, also in the Command Palette. A-Z sorts by the shown label (name:, else the id), case-insensitively, across every scope at once; equal labels keep file order. The choice is saved per workspace and written nowhere in your repository.

Every .local-workflows/tasks.yml in the tree counts, however deep — a monorepo keeps one beside each package it builds. Tasks also come from the workspace and your profile: see the three scopes. When more than one scope has tasks, rows sit under Folder / Workspace / Profile headings, narrowest first, and a wider task is hidden when a narrower one claims the same id.


Errors and silent failures

   
Top-level key not tasks: parses to zero tasks, no error
Unknown task key hard error
The same alias declared twice in plugins: hard error
Unquoted scalar containing ": " parses as a nested map
Task with no run:/uses: legal; logs Task has nothing to run
Task with no run:/uses:/needs: warning
Failed task dependents skipped transitively
Duplicate output: name, both tasks run the second to write fails; validation warned
Unresolvable ${{ }} expression fails immediately, naming the expression
shell: cmd off Windows validation error before running

Back to top

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


- 22-Aug-2026 08:02 PM +0000