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.
- Start from the sample
plugin.json- The implementation
- Secrets
- The sandbox
- Bundled plugins are not sandboxed
- 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
requiredarg 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
defaultis 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.