Writing a plugin

Writing your own plugin, in plain JavaScript. No build step, no dependency on this extension, nothing to register — drop a folder in and any task can uses: it.

Keys and defaults only: the cheat sheet.

  1. Start from the sample
  2. plugin.json
    1. What declaring them buys
  3. The implementation
    1. What you return
    2. What ctx gives you
  4. Secrets
    1. When the workflow names the variable
  5. The sandbox
    1. The boundary is the plugin’s own root
    2. npm dependencies
  6. Bundled plugins are not sandboxed
  7. Reloading

Start from the sample

Samples/HelloWorld/.local-workflows/plugins/greetV1/ in the repository is a complete working example — a manifest, an implementation, a declared secret and a returned output.

Copy the folder to ~/.local-workflows/plugins/ and it is available in every workspace you open as uses: greet@1.

Scope Location
folder <repo>/.local-workflows/plugins/, every open folder
workspace beside an open .code-workspace
profile ~/.local-workflows/plugins/

Two layouts: a single my-plugin.js, or a my-plugin/ folder with an index.js. Prefer the folder — see the sandbox.

A folder must be named <id>V<major>greetV1. A name that disagrees with the manifest fails at load, saying so.


plugin.json

Says what it is. Id and version live here and nowhere else, so the engine can validate a task without executing any of it.

{
    "id": "greet",
    "name": "Greet",
    "description": "Greets someone by name.",
    "version": { "major": 1, "minor": 0, "patch": 0 },
    "args": [
        { "name": "name", "type": "string", "required": true },
        { "name": "excited", "type": "boolean", "default": false }
    ],
    "outputs": ["greeting"],
    "execution": { "target": "index.js" },
    "secrets": ["GREET_TOKEN"]
}

args is an ordered array — order is information when a form is drawn from it. Each entry declares name and type (string, number, boolean, string[], object, array), and optionally label, description, required, default, options and multiline.

outputs lists the keys you always produce; outputsFrom names an arg whose string[] value is the output keys, for a plugin that cannot know them in advance.

Validated at load rather than mid-run — options on a non-string arg, an empty options, a default outside its own options, or a secretsFrom naming an arg that does not exist all fail immediately.

What declaring them buys

Real errors, before the task runs:

  • a missing required arg fails, naming it
  • a type mismatch fails, naming the arg and the expected type
  • an arg you did not declare fails — so a typo is caught, not ignored
  • a default is applied when the task omits the arg

A string narrows with options, checked when the file is read and again when a ${{ }} expression resolves to a value.

object is a map you interpret yourself — config with a shape the engine cannot usefully check. Validate what is inside it in execute and fail before doing any work, the way ai@1 checks each mcp: server has a command or a url. A manifest that says “object” and a plugin that then crashes on it is worse than no declaration at all.


The implementation

Plain CommonJS, one export:

module.exports = {
    async execute(args, ctx) {

        const greeting = `Hello, ${args.name}${args.excited ? "!" : "."}`;

        ctx.log(greeting);

        return { success: true, outputs: { greeting } };
    }
};

args arrives resolved and already checked — templates expanded and values validated against your manifest, before a line of your code runs.

What you return

Field  
success required
outputs stored under the task’s output:. No secrets.
message short reason, shown in the run panel on failure
messages turns appended to the task’s session:, in order
session { id } of a provider-owned conversation

What ctx gives you

Deliberately narrow — a plugin that reached for fs and net directly could never be constrained later without breaking every plugin ever written.

   
runId, taskId, attempt attempt is 1, higher on retry
cwd, root root is the same as ${{ root }}
env resolved non-secret environment
session this task’s turns, oldest first
secret(name) uncached, auto-masked
log(text, stream?) stdout | stderr | system
reportSession(id) report an external conversation as it opens
cancelled, onCancel(fn) cancellation

Secrets

You read a secret only through ctx.secret(), and only for names declared in secrets. There is no browsing process.env for more — a sandboxed plugin has no access to the environment, so anything undeclared is simply absent.

Values are read at call time, never cached, and registered with the run’s masker on the way out, so they are redacted if they ever reach a log.

Declaring secrets is what makes “what can this read” something reviewed in a manifest rather than discovered by watching what it does.

When the workflow names the variable

Sometimes you cannot know the name: one person’s Azure DevOps token is in ADO_PAT, another’s in WORK_ADO_PAT, and neither is wrong. Declare an arg to hold the name, and point secretsFrom at it:

{
    "args": [
        { "name": "patEnv", "type": "string", "default": "ADO_PAT",
          "description": "Environment variable holding the token." }
    ],

    "secretsFrom": "patEnv"
}
const token = ctx.secret(args.patEnv);

The arg’s default is what the declaration amounts to when a workflow says nothing. A workflow saying patEnv: WORK_ADO_PAT gets that variable instead — and only that one: the host resolves the name before building the sandbox’s secret map, so a plugin still receives a fixed set and never a way to ask for more.

A secretsFrom naming no arg, or naming one that is not a string, is refused at load. That failure is otherwise invisible — the plugin loads, the task runs, and the credential is simply absent, surfacing as “that token is not set” against a variable the user did set.

Fail with a message naming the variable. Never prompt for it.


The sandbox

Three layers, and only one of them is enforced below JavaScript:

Layer What it stops Where
A separate process reaching the engine, the run store, other sessions, VS Code PluginSandbox forks a worker per call
Node’s permission model reading outside the plugin’s own root, writing anywhere, spawning, native addons --permission --allow-fs-read=<plugin root>
A module allowlist require("fs"), net, http, dns, child_process, vm, … inside the worker

The permission model is the load-bearing one — Node enforces it, so no JavaScript trick gets around it. That is measured rather than assumed: with the permission flags removed, sandboxEscape.test.ts lands three escapes the module allowlist alone does not catch — process.binding("fs"), await import("fs"), and reaching process.mainModule.require through ctx.constructor.constructor.

Because the allowlist alone is not enough, the sandbox fails closed: a runtime that will not accept the permission flags gets plugins refused, not loaded unconfined.

A plugin may require these builtins and no others — all pure computation, none of them touching a file, a socket or a process:

assert  buffer  crypto  events  path  punycode
querystring  string_decoder  url  util  zlib

Anything absent is denied, so a module added to Node in a future version is denied by default rather than allowed by oversight. require("fs") fails by name, explaining what it would have let you do.

A plugin runs in a fresh process per call. Nothing it leaves behind — a timer, a global, a half-written buffer — can reach the next task.

The boundary is the plugin’s own root

Layout Root Can read
plugins/my-pluginV1/ (folder) the folder anything in it, including its node_modules
plugins/greet.js (single file) that one file nothing but itself

A loose file is granted the one file, not the folder it sits in — because that folder is everybody else’s plugin. Granting the directory would let require("./other-plugin/creds.json") read a neighbour’s credentials, and a relative require needs no fs, so the allowlist would never see it.

So: a single file is fine for something genuinely self-contained; anything with data files beside it needs a folder. A folder plugin owns its whole folder even when package.json points main at lib/index.js.

npm dependencies

A plugin may ship its own. Give it a folder, npm install what it needs, and require it normally:

~/.local-workflows/plugins/
  my-deployerV1/
    package.json
    index.js
    node_modules/
      semver/

Only its own node_modules counts. A package resolved by walking up into a shared folder is somebody else’s and is refused — and the permission model refuses the read that walk needs anyway.

A dependency sits inside the plugin’s root, so it is held to exactly the same rules: a library cannot require("fs") on the plugin’s behalf. Pick dependencies that compute, not ones that do I/O.

Ship node_modules with the plugin. There is no install step — nothing here downloads anything, by design.


Bundled plugins are not sandboxed

pwsh@1’s whole job is to run a shell command; sandboxing the escape hatch would be theatre. The line is trust, not capability: bundled ships with the engine, yours arrived with a repository.

It is also why a user plugin cannot wrap the Copilot SDK — it would need to spawn the CLI, and the sandbox denies child_process. An SDK-backed plugin has to be compiled in the way the bundled ones are.


Reloading

Plugins are read when the engine first starts. After editing one, reload the window (Developer: Reload Window) to pick it up.

A plugin that fails to load is surfaced as a warning naming the file and the reason — never silently skipped, because a missing plugin looks exactly like a workflow bug.


Back to top

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