settings.json
The engine’s one configuration file. One shape, read from at most two places, merged per key. This page is the whole file — every key, every default, and every error it can raise.
- The two scopes
- The whole file
sdd${{ }}— expressionsai— per-plugin defaultsai.mcpServersai.acpAgentsenv— the gate on MCP env readsvariables— your own namesdefaultFoldersToIgnoreScanningfeatures— the switchesdebug- Every error the
sdd:section can raise
The two scopes
| Scope | Where | Applies to |
|---|---|---|
| Workspace | <workspace root>/.local-workflows/settings.json — committed, reviewed in PRs |
everyone on this repository or workspace |
| Profile | ~/.local-workflows/settings.json |
you, in every workspace |
Merged per key, profile wins. When both files set the same key,
the value in your profile file is used. A personal MCP server declared
in your profile follows you into every repository and replaces a team
server with the same name. For ai.mcpServers the merge unit is one
server: an entry is taken whole, never merged field by field — half
a server declaration from each scope is not a server anybody wrote.
Every field has a default and both files are optional. A fresh install writes nothing and works.
A malformed file fails loudly and names itself — the error opens with the file’s full path. Plain JSON: no comments, no trailing commas. The one silent path is absence — a scope that declares nothing is the normal case.
The whole file
{
"sdd": {
"styles": ["custom"],
"source": "ado"
},
"ai": {
"ai@1": { "model": "auto" },
"mcpServers": {
"ado": {
"command": "npx",
"args": ["-y", "@azure-devops/mcp@2.9.0", "contoso",
"--authentication", "azcli"]
}
},
"acpAgents": {
"my-agent": { "command": "my-agent", "args": ["acp"] }
}
},
"env": {
"allowed": ["ADO_PAT"],
"denied": []
},
"variables": {
"Team": { "Name": "Payments", "Org": "contoso" },
"CurrentUser": "sj"
},
"defaultFoldersToIgnoreScanning": ["node_modules", "dist"],
"features": {
"docDiff": true
},
"debug": true
}
Seven top-level keys, and no others. An unknown one is an error, not
tolerance: a misspelled env that silently gates nothing is a security
setting that looks applied and is not.
| Key | What it is |
|---|---|
sdd |
everything spec-driven development reads |
ai |
per-plugin default args, plus MCP servers and ACP agents |
env |
the gate on ${{ env.NAME }} in an MCP declaration |
variables |
your own names, read as ${{ Team.Name }} from any file — see variables |
defaultFoldersToIgnoreScanning |
folders the workspace scan skips when there is no .gitignore |
features |
the switches on features that ship on their own flag — either file, see features |
debug |
your own extra diagnostics, and the one switch that opens an ai@1 session’s directory limit — profile file only |
Not in this file: the workspace-root pointer (localWorkflows.workspaceRoot, kept in the
.code-workspace — see
Workspaces), and anything
about a run — params, vars and env belong to the file being run.
sdd
It never reaches an AI. The extension reads it and hands each phase only the values that phase needs. Model choice, style id, and source settings stay in the engine.
| Key | Values | Default |
|---|---|---|
sdd.styles |
a list of custom, a worked example you copied in (kiro, spec-kit), or a style you wrote |
[custom] |
sdd.specsDir |
where spec folders live — workspace-relative, or absolute | .local-workflows/specs |
sdd.source |
manual | ado | gh | file |
unset — + New Spec asks each time |
sdd.uses |
a plugin reference | ai@1 |
sdd: is a closed set. Those four keys and no others — a fifth is a
hard error naming the valid ones, because a misspelled setting that
silently does nothing is worse than one that refuses to load. Plugin
args do not go here; they go under ai."ai@1" for every phase, or on a
stage’s ai: in your
style for one phase.
sdd.styles
Which style each entry enables: its phases, its gates, its prompts, and
the spec types it adds to the + New Spec menu. custom ships;
kiro and spec-kit
are worked examples you copy in; your own works the same way.
List more than one and + New Spec shows every enabled style’s spec types together, prefixed with the style’s name. A spec’s own opening document then says which style it follows — written once, when it is created, and read back from then on. With only one style listed, a spec with no such line just uses it; with two or more, an untagged spec cannot be placed and asks for the line to be added.
sdd.specsDir — where specs live
.local-workflows/specs under the workspace root by default. Point
sdd.specsDir somewhere else to move them: a relative path anchors to
the workspace (docs/specs), an absolute one stands alone — a vault
outside the repository, shared between projects
(D:/team/spec-vault).
In a plain folder the workspace root is the repository itself. With a
.code-workspace open it is whichever folder
localWorkflows.workspaceRoot names — by default the folder the
.code-workspace file sits in. One feature spanning five repositories
is one spec, not five.
sdd.source
Where the work item behind a spec comes from: written by hand
(manual), Azure DevOps (ado), GitHub (gh), or a file on disk
(file).
This is the one key with no default on purpose. Unset means the file does not say, and + New Spec asks each time — which is the difference between a team that has standardised and one that has not.
file opens a native file picker either way — chosen in the menu or set
here. The source can be standardised; the files are per-spec. The picked
files — one or several — are copied into the spec folder and named in
intake.md, so they need not be in the repository to start with and end
up in it regardless. Copied rather than linked: the ask goes into git with
the documents drawn from it, and a name that collides gets a numeric
suffix rather than overwriting.
sdd.uses — what runs the AI phases
Names the plugin, ai@1 by default. Nobody writes it until a second AI
plugin exists. The plugin’s args come from ai."<plugin ref>" — the
lookup is by the reference sdd.uses names, so a team that points it at
a different plugin gets that plugin’s defaults.
One phase, different settings — not here
There used to be a sdd.stages.<id> block for this. It moved to the
style, where a phase is already described:
# .local-workflows/styles/<your-style>/style.yml
stages:
- id: design
ai:
model: gpt-5
reasoningEffort: high
The merge is per key, not per block: design above keeps whatever
ai."ai@1" set and changes only what it names. Naming one setting never
silently drops the others.
Why it moved: only a style knows which of its phases is the hard one, so that belongs beside the phase rather than in a second file. Two files answering “why is this phase on that model” meant reading both to know.
sdd.uses did not move — which plugin runs a phase is yours, not the
style’s, and a style naming one would not run for a team on a different
plugin. Neither did the defaults below: ai."<plugin>" still sets what
every phase starts from.
A sdd.stages block still in your file is a hard error naming where the
settings went, rather than a key that sits there looking applied.
The keys are the plugin’s, and ai@1 is a passthrough — so beyond
provider and model, what belongs there is whatever the provider
understands. reasoningEffort, contextTier, reasoningSummary and
enableExperimentalMode are ghcp’s; reasoningEffort in particular is
only valid on a model whose capabilities report it, which auto does not
— so pin a model when you set it.
${{ }} — expressions
Anywhere in this file, and anywhere in a
style’s style.yml.
The same notation tasks.yml uses, resolved before a single key is
read — so it works under any key, at any depth, in either file.
{
"sdd": {
"specsDir": "${{ workspaceFolder }}/docs/specs"
},
"ai": {
"ai@1": { "model": "${{ env.TEAM_MODEL }}" }
}
}
What is readable. The four anchors — workspaceFolder,
workspaceConfig, home, cwd — plus env.NAME, plus every name
under variables, bare. cwd equals
the workspace root here: nothing is running, so there is no working
directory of its own.
What is not. vars, params and run.context belong to a run,
and these files are read before there is one. Asking for one is an
error that says so, not a silent blank.
A value that is nothing but an expression keeps its type.
"model": "${{ env.MODEL }}" stays a string; a
templated number stays a number.
The env gate follows the destination, not the spelling. There is one spelling now, so which rule applies is decided by where the value goes:
| Where | Rule |
|---|---|
inside ai.mcpServers or ai.acpAgents |
the full gate — env.allowed (or a workflow’s env:, or allowedEnv:) must grant the name |
anywhere else in this file, and in style.yml |
no grant needed; env.denied still vetoes |
tasks.yml |
no grant needed; unchanged |
A declaration under ai.mcpServers or ai.acpAgents is handed to a
third-party process, so it is granted a name. A value the engine
consumes itself is only vetoed. env.denied wins everywhere — it
is the one thing that is a veto rather than a default.
An unresolvable expression fails loudly, naming the file. It is
never left as literal text — a typo’d
${{ workspaceFoldr }} becoming part of a real path
is the failure this exists to prevent.
With no workspace open there is nothing for workspaceFolder to
mean, so a file that writes an expression is refused rather than
resolved to an empty string. A file that writes none is unaffected,
which is nearly every file.
ai — per-plugin defaults
Every key under ai: other than mcpServers and acpAgents is a
plugin reference, spelled exactly as a task would spell it, holding
that plugin’s default args everywhere it runs:
{
"ai": {
"ai@1": { "model": "auto" }
}
}
A workflow file’s own plugins: entry, an SDD stage entry, or a task’s
own args: win over it — one key at a time, not one block at a time.
One default is worth knowing on its own: model: auto — the provider
picks. A pinned model name goes stale, and a team that has not formed
an opinion should not be made to hold one.
Making a provider the default
provider is just another arg here, and this is the one place that
changes it for every ai@1 task and every SDD phase at once —
tasks.yml workflows and SDD both read the same ai."ai@1" defaults
before anything more specific overrides them.
{
"ai": {
"ai@1": { "provider": "kiro-cli" }
}
}
Nothing ships this way — ai@1’s own manifest defaults provider to
ghcp, and this file states no opinion until you write one. Writing
"provider": "ghcp" here yourself would be a no-op, restating what the
manifest already does; the reason to write this key at all is to name a
different default, kiro-cli or otherwise.
This only reaches ai@1. The key it lives under is a plugin
reference (ai."ai@1", exactly), and a phase whose style overrides
sdd.uses to a different plugin gets that plugin’s own defaults
instead — a provider written here would reach nothing for it. Point a
default at whichever plugin sdd.uses actually names, if it is not
ai@1.
A single stage, or a single task, still wins over this with its own
provider: — the precedence above applies here exactly as it does to
model.
ai.mcpServers
MCP servers for every ai@1 session, in the standard mcpServers entry
shape every MCP README shows — copied unedited, plus four engine keys:
These same declarations are what
mcp@1 calls directly,
with no session and no model. The block still lives under ai: for now,
which is a name older than that second reader.
{
"ai": {
"mcpServers": {
"ado": {
"command": "npx",
"args": ["-y", "@azure-devops/mcp@2.9.0", "contoso"],
"env": { "ADO_PAT": "${{ env.ADO_PAT }}" }
}
}
}
}
| Key | ||
|---|---|---|
command |
string |
the executable for a stdio server. Write it portably — npx, not npx.cmd |
args |
array |
|
url |
string |
the endpoint for an http server |
type |
string |
stdio, sse or http. Inferred when absent — a command means stdio, a url means http, so sse must be said. local is an accepted alias for stdio |
env |
object |
environment for the server process. ${{ env.NAME }} is expanded under the gate |
headers |
object |
|
timeout |
number |
milliseconds. Copilot’s own per-tool-call timeout (default 180000) — not a connection or handshake timeout. Copilot has a long-standing bug where this value is silently ignored (github/copilot-cli#172, still open); a server with genuinely slow individual tool calls may time out regardless of what is set here |
disabled |
boolean |
true skips the server entirely |
disabled is the engine’s own and is stripped before the runtime ever
sees the server. Nothing here says which tools a session gets —
that is the task’s decision, mcp:
below.
Three keys used to live here and are gone. autoApprove and
disabledTools chose tools per server; requiresWarmStartup opted a
server into a preflight probe, built for a hang that was really the
server waiting on an interactive sign-in no task can complete. A file
still carrying any of them fails the task naming the key and what to do
instead.
Wire names. An MCP tool’s wire name is <serverKey>-<toolName> — the
server declared as ado above surfaces wit_work_item as
ado-wit_work_item. A task’s mcp.tools takes the bare name and
the runtime adds the prefix; a task writing its own
availableTools:/excludedTools: must write the full wire name itself,
and one that writes the bare name there matches nothing, silently. The
copy button on a tool row in the MCP view copies the bare name.
mcp: — which servers one task gets
Everything above is what the machine has. Every ai@1 session gets
all of it unless the task says otherwise — and with six servers declared,
most tasks do not need six. A task’s mcp: arg picks:
- uses: ai@1
args:
prompt: Update the work item with what shipped.
mcp:
servers: [ado] # only this server
tools:
ado: [wit_get_work_item, wit_update_work_item] # only these tools of it
| Key | ||
|---|---|---|
servers |
array |
server keys as declared above. Absent means every server. [] means none |
tools |
object |
bare tool names per server key. Absent means every tool of each selected server |
Settings still win where they say no. A server disabled: true here
stays out even when a task names it. And a task cannot declare a
server: mcpServers: in a task’s args: is stripped on purpose,
because servers are machine setup and a committed workflow must not be
able to start one. Tools have no machine-level switch: mcp.tools is
the only place that narrows them, and a task that says nothing gets
them all.
A name ai.mcpServers does not declare fails the task, naming the ones
it does. Silent would be the wrong choice: a typo would start the
session without the one server the prompt is about, and the agent would
report it cannot find the work item.
The same key works as an ai@1 default — "ai": { "ai@1": { "mcp": {
"servers": ["ado"] } } } — and on a style stage’s ai: for SDD, where a
stage’s mcp: replaces the default’s whole, not key by key. See
custom styles.
command is made runnable for you
On Windows there is no npx — there is npx.cmd, a batch shim, and
process creation does not consult PATHEXT. So the declaration stays
portable and the engine makes it run: a command with no file extension is
resolved through the shell on Windows. A command that already carries
one — node.exe, an absolute path — is left exactly as written.
It is a fourth source, not a replacement
The Copilot CLI’s own discovery files still apply:
| File | Scope |
|---|---|
~/.copilot/mcp-config.json |
every session on this machine |
.mcp.json in the workspace |
this repository — commit it |
.github/mcp.json in the workspace |
same, GitHub’s preferred spot |
ai.mcpServers is layered on top of those explicitly. The difference
worth caring about is that it is the only one the extension can show you,
toggle for you, and merge across scopes.
The editor’s own mcp.json — user-level or .vscode/mcp.json — is
not among the CLI’s files and is not bridged across. A server that
answers in the chat panel says nothing about a task.
A mcpServers: block in a workflow’s or a task’s args: is not
supported. It is deleted before the session is created, from every
direction — MCP servers are machine setup, not workflow definition.
A server signed in once stays signed in
A server that authenticates over OAuth has nobody to ask during a run — no window belongs to the task. Tokens are kept where the runtime keeps them rather than discarded with the session, so one interactive sign-in holds for every task after it.
The MCP Servers view
The sidebar’s MCP Servers section is a view over these two files,
collapsed by default and hidden entirely until a scope declares a server.
Every server both scopes declare shows up, labelled with which file
declared it. Ticking a server’s checkbox writes disabled back into
that same file, so what the panel shows and what a session resolves
can never be two different truths. Tool rows have no checkbox — which
tools a session gets is the task’s mcp:
— but each has a copy button that puts the bare tool name on the
clipboard, ready to paste into that list.
| Command | |
|---|---|
| Local Workflows: Start MCP Server (Discover Tools) | spawn (or reach) the server, handshake, list its tools, disconnect |
| Local Workflows: Reload MCP Servers | re-read both files |
| Local Workflows: Open MCP Config | open the file that declared the selected server |
Start does not leave anything running. The Copilot CLI spawns its own server per session, so a probed server is not a process your tasks talk to — discovery is the honest version of “start”.
ai.acpAgents
Agents that speak the Agent Client Protocol
(ACP), registered as a provider under the id you give them.
ghcp and claude are separate, built-in providers with their own
vendor SDKs; this is the general-purpose door for anything else that
speaks ACP.
Seven are built in. With nothing written here, these ids already
work as a task’s provider: - you only need the vendor’s CLI
installed and signed in:
| id | runs |
|---|---|
kiro-cli |
kiro-cli acp |
copilot-cli |
copilot --acp --stdio |
claude-cli |
claude-agent-acp |
gemini-cli |
gemini --acp |
codex-cli |
codex-acp |
cursor-cli |
cursor-agent acp |
opencode-cli |
opencode acp |
Write an entry under one of those ids to replace it — the whole entry, not one key — or under any other id to add an agent:
{
"ai": {
"acpAgents": {
"kiro-cli": {
"command": "C:/tools/kiro-cli.exe",
"args": ["acp"]
}
}
}
}
| Key | ||
|---|---|---|
command |
string, required |
the executable that speaks ACP on stdio when run with args |
args |
array |
arguments that put it into ACP mode — ["acp"] for Kiro |
env |
object |
environment for the agent process. ${{ env.NAME }} is expanded under the gate, same rule as ai.mcpServers |
Once declared, provider: <id> on an ai@1 stage or task runs on it —
same session shape as ghcp or claude: a response, and any tool call
the agent makes is auto-approved unattended, same as the other two
providers. ai.mcpServers reaches it too, forwarded on the same terms
— see Agent Client Protocol.
The id cannot be ghcp or claude. Those two are checked first,
always — an entry under either name is never reached, silently. Reaching
either vendor through ACP instead of its built-in provider needs a
different id.
Per-agent setup, gotchas and what each built-in id needs installed (Kiro, Claude and GitHub Copilot via ACP, and adding one this page doesn’t name): Agent Client Protocol.
env — the gate on MCP env reads
${{ env.NAME }} inside any string value of an MCP
declaration reads the machine’s environment — but only if something a
reviewer can see grants the name. A committed settings file arrives with
the repository, so nothing reads your environment by default.
This gate is about MCP declarations, not about the notation. The
same ${{ env.NAME }} elsewhere in this file needs
no grant — see expressions. What earns the gate is that
the value is handed to a third-party server process.
Three grants, one veto:
| Granted by | |
|---|---|
env.allowed |
this file — reviewed in the same PR as the servers that use it |
the workflow’s own env: block, and .env values |
the file the task lives in. Its value also wins over the machine’s |
allowedEnv: on the ai@1 task |
the workflow’s author, seen by its reviewer |
env.denied |
always wins. A name written here is unreadable no matter what any repository says |
A name nothing grants fails the task with an error saying how to grant it — rather than expanding to an empty string and starting a server that cannot authenticate.
Names only, never values. Nothing here ever holds a secret.
variables — your own names
Values you name once and read everywhere. For the case where one committed workflow or style serves several teams, and each team — or each person — has to fill in a different piece of it.
{
"variables": {
"Team": { "Name": "Payments", "Org": "contoso" },
"CurrentUser": "${{ env.USERNAME }}",
"Repo": "${{ home }}/src/${{ Team.Name }}"
}
}
Then, in a tasks.yml, a style.yml, an SDD style’s phase prompts
(prompts/*.md), or anywhere else in this file:
vars:
branch: ${{ Team.Name }}/${{ CurrentUser }}
tasks:
build:
cwd: ${{ Repo }}
run: echo "building for ${{ Team.Name }} (${{ Team.Org }})"
Read bare. Each top-level key is its own name — ${{ Team.Name }},
not ${{ variables.Team.Name }}. Nest as deep as
you like; a path reads into it the same way ${{ vars.a.b }}
does. A whole mapping is readable too — ${{ Team }}
keeps its type.
A value may read the four anchors, env.NAME, and the other
variables, in any order of declaration. It may not read vars, params
or run.context: settings are read before any run exists. Two
variables that read each other fail the load, naming both.
Both files may declare it. The two merge one leaf at a time, so the
workspace file can carry Team.Name while your profile carries
Team.Repo under the same key. Where both files set the same leaf your
profile file wins, and the run’s log says so on one line —
variables.Team.Name: '<profile file>' overrides '<workspace file>' —
so you can see why the value you wrote is not the value the run read.
A name an expression already means is refused — home, env,
vars, params, run, cwd, workspaceFolder, workspaceConfig,
spec, and the run stamps such as runId and dateToday. The load
fails naming the file, so a variable can never shadow a built-in or be
shadowed by one.
A name nothing declares fails the task by name, the same way an unknown built-in does, and the error lists your variables among what is available. No defaults, on purpose: a missing team value is exactly the setup gap to catch.
Not secrets. A variable is a plain value handed to whatever reads
it, including an MCP server’s command line. env.denied still vetoes
an env.NAME read inside a variable, but nothing else gates one —
put a token in env.allowed, not here.
With no workspace open, a profile whose variables are plain values still loads. One that writes an expression is refused, like any other value in this file — see expressions.
Where it shows. The run panel’s Env tab lists them under Settings variables, above Params, as the run read them.
defaultFoldersToIgnoreScanning
Folders the workspace scan skips when the repository has no .gitignore
to say so itself. Names, not globs. With a .gitignore, the scan follows
that instead and this is not consulted.
Declaring it replaces the built-in list for that scope rather than adding to it. The built-in list is:
node_modules .git .vscode .idea TestResults
bin obj dist out coverage build target
logs tmp temp
features — the switches
Some things ship on a switch rather than for everyone at once. Every switch this build knows, with what it starts as:
| Flag | What it does | Default |
|---|---|---|
docDiff |
the What changed button on a spec document — the last run’s changes in red and green, with the words that moved picked out. See What changed | on |
sessionsPane |
lists every AI session a spec has had in a pane on the right of the spec panel, in place of an AI Session tab under each phase | on |
liveToolOutput |
streams a running tool’s output into its row in the log instead of waiting for the call to end | off |
sessionCost |
shows what a session cost in money in the usage card. The figures are recorded either way, so turning it on shows the whole history back | off |
{
"features": {
"docDiff": false,
"sessionCost": true
}
}
Name only the ones you want to change; every flag you leave out stays at its own default. A name this build does not know is an error, not a silently ignored line — a flag that has been removed, or misspelt, would otherwise read as set and do nothing.
Either file may set them. Put a flag in your profile file to change it for yourself, or in the workspace file to change it for everyone who opens the repository. When both files name the same flag, your profile file wins, the same as every other key. The team picks the starting point; you can always set a flag back for yourself.
debug
Extra diagnostics, for you. false unless you say otherwise:
{
"debug": true
}
It also takes the limit off an ai@1 session. Normally a session may
only read and write inside the task’s cwd and the folders open in the
editor; with debug on it may go anywhere you can, and the operating
system sandbox around its shell goes off with it. The session says which
of the two it got in its first log lines, so a run you read back later
cannot be mistaken for a confined one. See
where a session may read and write.
Your profile file only — ~/.local-workflows/settings.json. This is
the one key the workspace file may not set, and that placement is the
whole reason it is safe to offer: it is a personal switch, a committed
file must not turn debug output on for everyone who opens the repository,
and a repository must not be able to unlock the limits on the sessions
working in it. A workspace settings.json that sets it fails, naming
where it belongs, rather than being quietly dropped.
Every error the sdd: section can raise
All raised when the file is read, not in the middle of a run:
| The file says | The error |
|---|---|
| anything that is not valid JSON | '<path>' is not valid JSON: ... Plain JSON only - no comments. |
a top-level key that is not sdd, ai, env, variables, defaultFoldersToIgnoreScanning, debug or features |
'<path>' has an unknown setting '<key>'. Valid sections: ... |
debug in the workspace file |
'<path>' sets 'debug', which is a personal setting. Put it in your profile file, '~/.local-workflows/settings.json'. |
debug that is not true or false |
'<path>': 'debug' must be true or false. |
a flag under features: this build does not know |
'<path>' has an unknown feature flag 'features.<name>'. Valid flags: ... |
a flag under features: that is not true or false |
'<path>': 'features.<name>' must be true or false. |
a variables: name an expression already means, such as home |
'<path>' declares a variable named '<name>', which ${{ <name> }} already means. Pick another name. |
a key under sdd: that is not one of the four |
settings.json has an unknown key 'sdd.<key>'. Valid: styles, specsDir, source, uses. |
sdd.stages:, which moved to the style |
settings.json has an unknown key 'sdd.stages'. Per-phase settings moved to the style: a stage's 'ai:' in style.yml says what that phase runs on, and 'sdd.uses' still says which plugin runs every phase. |
sdd.style:, the old singular key |
settings.json has an unknown key 'sdd.style'. 'sdd.style' became 'sdd.styles' - a list, since a workspace can now enable more than one at once. |
sdd.styles: that is not a non-empty list of ids |
settings.json 'sdd.styles' must be a non-empty list of style ids. |
sdd.uses: that is not text |
settings.json 'sdd.uses' must be text. |
a sdd.source: value not in its list |
settings.json has 'sdd.source: x' - expected one of 'manual', 'ado', 'gh', 'file'. |
sdd.checkpoints:, a setting this build removed |
settings.json has an unknown key 'sdd.checkpoints'. 'sdd.checkpoints' was removed. Nothing enforced it - a checkpoint was a row in tasks.md and no more - so it is gone rather than half-kept. Delete the key. |
sdd.stages gets a message of its own rather than the plain list,
because somebody carrying that block has per-phase settings that used to
work — and a list of valid keys would leave them guessing which one
replaced theirs.
A key inside ai."<plugin>" is not checked here — those are plugin
args by design, and the plugin validates its own. The JSON schema the
extension contributes for settings.json is what flags a genuinely
misplaced key while you type.