Anvil for VS Code
The editor surface for the Anvil agent. The conversation is an editor tab
that streams a turn, typesets the model's Markdown, shows its reasoning in a
collapsed row above the answer, and gives every tool call a card with what went
in and what came back; it stops for your approval before anything writes to the
workspace. The activity bar lists this workspace's past sessions, one click
to continue, and the composer floats over the transcript so the conversation
scrolls underneath it.
This extension holds no agent logic. It spawns the anvil-host sidecar — a
stdio program built from the Anvil Go SDK — and renders what that process
reports. The protocol between the two is chapter 35 of the design docs
(doc/design/v1.0/35-vscode-extension.md); the TypeScript mirror of it is
src/protocol.ts.
Installing a packaged build
Download or build the VSIX for your platform and install it:
code --install-extension anvil-vscode-0.38.3-win32-x64.vsix
Nothing else is needed: the anvil-host sidecar for that platform travels
inside the package, so a machine with no Go toolchain runs it. Reload the
window, open a folder you trust, run Anvil: Set API Key, and then
Anvil: Open Chat — or click a session in the Anvil view in the activity bar,
which opens the tab onto it.
Running it from source
npm install
npm run compile
npm run build:host # builds source/cmd/anvil-host into bin/
Then press F5 (or the Run Extension launch config) to open a second
VS Code window with the extension loaded, and run Anvil: Open Chat. Run Extension (trusted workspace, no reload) opens that window straight onto this
repository, which is the quicker loop if you are working on both sides at once.
The sidecar is found in this order, and the first hit wins:
anvil.hostPath, when it is set — a path you chose, checked against
nothing, so a host you built yourself always works.
- The bundled binary,
bin/anvil-host[.exe] inside the extension. This is
the packaged case and the F5 case, and it is the only source that is verified
before it runs (see Provenance).
anvil-host on PATH — for a build you installed yourself. A missing
binary fails with the spawn error and a note about which of the three was
being looked for.
Packaging
npm run package:vsix # this machine's platform
npm run package:vsix -- --target linux-x64
npm run package:all # one VSIX per target, in sequence
npm run smoke:vsix -- --vsix anvil-vscode-0.38.3-win32-x64.vsix
Targets: win32-x64, win32-arm64, linux-x64, linux-arm64, darwin-x64,
darwin-arm64 — the same list src/hostBundle.ts resolves against, so a
package can only be built for a platform the extension knows how to find a host
on. Each VSIX carries exactly one sidecar, cross-compiled with CGO_ENABLED=0
by scripts/build-host.mjs; the platform has to be chosen at package time
because nothing in an installed extension says which machine it will run on.
A packaged VSIX contains the compiled JavaScript, the media, the README, and
bin/ — never src/, the tests, the scripts, or the workspace's own .anvil/
state.
Provenance
Building the sidecar writes bin/hosts.json beside it: the file name, its
sha256, its size, the Go toolchain, the protocol revision, and when it was
built. At spawn the extension hashes the bundled binary and refuses to run it if
the digest is not the one recorded — the check is skipped for a configured path
or a PATH lookup, where there is nothing truthful to compare against.
That is a check on the package, not a signature: it catches a package that was
damaged, half-replaced, or assembled from a stale bin/, which are the ways
this actually goes wrong. It is not a defense against someone who can write to
the installed extension directory — they can rewrite the manifest too. What it
does mean is that the binary that runs is the binary that was built with the
JavaScript that is running it.
initialize then asserts the protocol revision on the wire, and the host's
version banner is compared against the extension's release line; a mismatch
names both sides and stops rather than failing later inside a tool call.
Troubleshooting
| Symptom |
Cause and fix |
could not start the Anvil host: … (no bundled host for this platform…) |
The VSIX is for another platform. Install the one matching your OS and arch, or build source/cmd/anvil-host and set anvil.hostPath. |
the bundled Anvil host … does not match the checksum recorded when it was built |
The installed package changed after it was built. Reinstall it, or point anvil.hostPath at a binary you built. |
… speaks editor-host protocol N; this extension speaks M |
The sidecar and the extension are from different revisions. Reinstall the extension; if you set anvil.hostPath, point it at a matching build. |
Anvil refuses to run in an untrusted workspace. |
Expected. Trust the folder (Workspaces: Manage Workspace Trust) — the extension will not start an agent that can edit and execute in a folder you have not trusted. |
| The chat tab opens but nothing streams |
The provider refused the run; the tab says which and why. Check Anvil: Set API Key and anvil.baseUrl / anvil.model. |
| The session list says it could not list sessions |
The sidebar reports the spawn failure in the row's tooltip. Refresh it after fixing the cause; the list is cached, so a failure is not retried on its own. |
| A session row shows an agent id instead of a title |
The session was never prompted, so there is no first message to name it by. The extension falls back to the id rather than inventing a title; the tooltip has the workspace, model, timestamps and run count either way. |
| Nothing happens in a folder with no files opened |
The host's working directory is the workspace folder, so a single-file window has none. Open a folder. |
Configuring a model
| Setting |
Meaning |
anvil.provider |
openai or anthropic |
anvil.model |
Model id; empty uses the host's default |
anvil.baseUrl |
OpenAI-compatible base URL; empty uses the provider default |
anvil.maxSteps |
Step limit per turn; 0 uses the host's default |
anvil.store |
jsonl, sqlite, or empty for the host's default |
anvil.vision |
Accept pasted images; off by default |
anvil.rules |
Load AGENTS.md and .anvil/rules/*.md; on by default |
anvil.cursorRules |
Also load .cursor/rules/*.md|.mdc; on by default |
anvil.permissions |
Read the workspace's .anvil/permissions.json; on by default. Off means the session ignores the file — the rules and the defaultMode it may declare — and an approval card has nothing to offer under "always allow", because a rule written into a file this session does not read would not be in force |
anvil.hostPath |
The sidecar to spawn; empty uses the bundled one, then PATH |
The API key is not a setting. Run Anvil: Set API Key; it goes into VS
Code's SecretStorage and from there into the child process environment
(ANVIL_API_KEY). It is never written to a settings file, an output channel, or
a log line.
What it will and will not do
- Refuses untrusted workspaces. The extension declares
untrustedWorkspaces.supported: false, and HostManager.assertTrusted()
throws before any spawn — so a folder you have not trusted cannot run the
agent, whatever the UI does.
- Asks before mutating calls.
write, edit, shell and verify raise an
approval prompt by default; so does any tool the host adds from outside the
SDK (MCP, plugins). The prompt shows a preview the host computed — the argv
for a command, the diff for a file, a summary otherwise. (Rendering the diff
inside the prompt is not the same as the diff-review surface, which is
p_0.35.5.)
- Remembers a pattern, not a tool. "Always allow" offers the shapes that
would actually decide the call in front of you — this exact command, commands
starting with these tokens, anything the analyzer can prove read-only, this one
file — and each one writes a rule into
.anvil/permissions.json. The host
re-reads that file and settles the call itself, so the choice takes effect
immediately and is visible in the file afterwards. When the call is one no rule
shape can describe (a && line, say), the card offers a session-only
entry instead, marked as such. A call that runs without a prompt says
allowed by rule N: … on its card, and a refused one shows the rule's reason.
A rules file that will not compile is opened where it is broken, with a
diagnostic on the line, rather than failing the session with a bare error —
and Anvil: Open Permission Rules is how you get to the file.
- Kills its child. One host per window, stopped on deactivate, on a folder
change, and on a settings change (the host reads its configuration at spawn).
- Tells you what it read. The tab reports which rule files this workspace
loaded —
AGENTS.md, .anvil/rules/*.md, and optionally .cursor/rules/* —
and says so plainly when one was skipped or cut short by the size budget.
- Typesets the reply, and shows the thinking when the provider sends it. A
reply is rendered as Markdown — paragraphs, headings, lists, quotes, fenced
code — by a parser that returns a tree of plain objects and nothing else: the
panel builds the nodes, so nothing the model writes can add markup or script
to the page. Reasoning that an OpenAI-compatible provider streams
(
reasoning_content, or reasoning) appears in a collapsed Thinking row
above the answer, which closes itself once the answer starts. It is
display-only — never stored, never replayed, never sent back. A link is only
clickable if it is http or https, and the extension checks that again
before asking the operating system to open it; anything else stays the literal
text it was written as. The Thinking row is provider-dependent: Anthropic's
extended thinking must be echoed back with a signature the SDK does not carry
yet, so it does not appear there.
- Reopens the change a call made. Every finished card for a call that wrote
or edited a file offers Review changes, which opens VS Code's own diff
editor. It is deliberately not the same thing as the prompt's preview: a call
that ran under
accept edits, under bypass, by an allow rule, or with
anvil.permissions off never showed you anything, and those are the calls a
way back to matters most for. The diff is the file as the turn found it
against the file on disk whenever the extension still holds that before-image
— the turn that just finished. Every older card, and an apply_patch (which
spans files and has no single "before"), opens what the call itself carried,
and the tab's title says which it is: what this call replaced, the body
this call wrote, or the patch's own text. A shell command that redirects
into a file changed it without naming it, so its card offers nothing rather
than guessing. A failed call offers nothing either: a change that did not
apply is not a change. What it does not do is accept, revert or undo
anything — it is a view of what happened.
- Keeps the conversation across a mode change.
ask, accept edits,
plan, no asking, and bypass are session options in the SDK, so switching
one closes the session and re-opens it under the same agent: same history, new
mode. The mode is remembered per workspace, and the select's Workspace
default entry gives up that memory so the workspace's own file decides again.
You are told which mode is in force and where it came from: one from
.anvil/permissions.json is announced once per session, and a declaration the
host would not honour — bypass may not be granted by a project file — is a
warning carrying the host's own words. no asking runs only what the rules
already allow and never prompts; bypass is flagged in the composer. Both stop
at the asking, not the policy: a deny in .anvil/permissions.json still
refuses the call, a written ask rule still raises the card even under
bypass, and the editor loads that file — and .anvil/hooks.json, which no
client loaded before 0.36 — for every new session. A mode change is refused
while a turn is running. See
doc/design/v1.0/37-permission-modes.md.
- Keeps the conversation when you close the tab. The tab is a view of a
session, not the session itself: closing it leaves the agent and the host
running, and reopening it re-announces the session, the mode and any approval
still waiting for an answer. Opening a session from the sidebar reuses the one
tab and empties it first, so two conversations are never stacked in it.
- Draws a stored conversation back into the tab. Opening a session — or
reopening the window onto one — fills the tab with what happened in it: your
messages, the replies as Markdown, the tool cards (each still able to open the
change it made), and a marker where each run ended, with its steps and tokens.
Reasoning has no row, because it was streamed and never written down; a call's
card does not claim whether it succeeded, because that was never written
either. A conversation longer than the reader's cap shows its most recent part
and says how many earlier turns were left out. The window also remembers which
session it was in, so a reload comes back to that conversation rather than to
an empty tab — and if the session is no longer in the list it opens fresh
instead of trying to resume something that is gone. An older host that does not
answer
session.transcript leaves the tab usable with no replay and no error.
See
doc/design/v1.0/38-session-identity-and-replay.md.
- Ships its own sidecar. A platform VSIX carries the
anvil-host binary it
was built with, verified against the checksum recorded at build time before it
is run. The API key is not in the package: it is in VS Code's SecretStorage,
and from there in the child process environment.
- Does not have checkpoints, rewind or forking. A transcript is a record,
not a cursor: replaying a conversation cannot be resumed from, edited, or
branched. Reasoning is the one part of a turn that does not come back — it was
never persisted — and a round whose record had not landed when the tab
re-attached mid-run is in neither the replay nor the stream. See
doc/roadmaps/v1.0/m_0.38/.
Sessions
The Anvil view in the activity bar is this workspace's session list: one row per
past session, grouped by when it was last used, newest first, with the model and
run count beside it. A click opens the chat tab onto that session. The run icon
in the view's title bar starts a new conversation and the refresh icon re-reads
the list; Anvil: Refresh Sessions does the same from the palette.
The rows are what session.list returns and nothing more. A row is labelled with
the session's title — the first thing you typed in it, one line and truncated —
and the tooltip carries the whole agent id along with the workspace, model,
timestamps, run count and tokens. A session nobody prompted has no first message,
so it falls back to the shortened id rather than to a title the extension made
up. A session that the store has no timestamp for is listed under Undated
rather than being filed under a day it may not belong to, and an empty list says
which of the three reasons it is: no folder open, an untrusted workspace, or a
host that could not be started (with the host's own error).
The list is cached and only re-read when something changes — a run starting or
finishing, a session opening, the host restarting. The view never spawns a host
just to draw itself.
Commands
Type / in the composer for the catalogue. Built-ins:
| Command |
What it does |
/plan [request] |
Switch to plan mode; anything after it is sent as the request |
/model [id] |
Choose the model this conversation uses |
/resume [agent id] |
Continue a past session in this workspace |
/clear |
Start a new conversation |
/help |
List the commands |
Anvil: Open Permission Rules (also the Permissions button on the composer) opens this
workspace's .anvil/permissions.json, creating it with an empty rule set if it is not there
yet. That file is what decides whether a tool call runs, stops to ask you, or is refused
outright — see Permission modes and rules.
Skills in the workspace (.anvil/skills/<id>/SKILL.md) are listed as commands
of their own: running one turns that skill on for the conversation. @
references a file or folder for the next prompt.
Permission modes and rules
The mode decides only whether a call stops to ask; the rules decide what it may do.
The select in the composer has five modes and one entry that is not a mode:
| Mode |
What it does |
| Ask |
Every gated call waits for you |
| Accept edits |
In-workspace edits apply without asking; shell, verify, egress and external tools still ask |
| Plan |
The workspace-changing tools are withheld and the agent is told to plan |
| No asking |
For a run with nobody watching: only calls the rules already allow run, and nothing ever prompts |
| Bypass |
Nothing prompts — but a rule you wrote that says ask still does, and a deny still refuses |
| Workspace default |
No opinion: the mode is whatever .anvil/permissions.json declares, or ask |
The mode is remembered per workspace; Workspace default gives that memory up, and the
entry's label then shows the mode actually in force (Workspace default (Plan)). A mode that
came from the file is announced once per session, so a repository cannot quietly decide how
much runs unattended. .anvil/permissions.json may declare one:
{ "version": 1, "defaultMode": "accept_edits", "rules": [] }
"defaultMode": "bypass" is refused — a project file may not grant a mode that approves
everything without showing it — so the session starts in ask and the panel tells you why, in
the host's own words. Anything else the ladder does not have is refused the same way, and a
declaration that is not a bare lowercase word is a file error with a line number, like a
misspelled effect. Turning anvil.permissions off ignores the declaration along with the
rules.
Tests
npm test # compile, lint, build the host, run everything
npm run test:unit
npm run test:integration
npm run smoke:vsix -- --vsix <file> # open a built VSIX and run the host it carries
Two kinds of test live under test/integration/. Most drive
test/fixtures/fake-host.mjs — a scripted host that speaks the real protocol,
so the extension's client, session state machine, and rendering can be tested
without a key or a network. The rest drive the real anvil-host binary with
the model call routed at a port nothing listens on, which pins the Go side of the
wire; they are skipped when the sidecar has not been built.
src/settings.ts, src/hostManager.ts, src/chatPanel.ts, src/sessionTree.ts
and src/extension.ts import vscode and are therefore not covered by these
tests — they need an extension host. Keep them thin; the behaviour worth testing
belongs on this side of the vscode boundary. src/commands.ts,
src/hostBundle.ts, src/sessionList.ts, src/rulesFile.ts, src/editDiff.ts,
src/transcript.ts and src/reattach.ts are split that way on purpose: they
decide what a typed line means, which binary to run, what the session sidebar
shows, what the rules file's skeleton and error positions are, what change a
finished tool call carried and how it should be opened, what a stored
conversation folds into, and whether a reloaded window comes back to its session
or starts fresh — and the vscode-bound modules above only carry those
decisions out. test/unit/chatLayout.test.ts is the third kind of check: it reads
media/chat.css and media/chat.js as text and asserts the layout invariants
those two files have to agree on, since nothing here can render them.
test/unit/markdown.test.ts loads media/markdown.js the same way — by
absolute path, for its side effect — and pins the property the transcript's
incremental renderer rests on: that re-parsing a document from its last block's
start reproduces that block exactly. test/unit/linkPolicy.test.ts reads the
two files that implement the link policy and checks they still both exist and
still allow the same schemes, which is the only part of that policy this suite
can see. test/unit/permissionKinds.test.ts is the fourth: it reads
media/chat.js, src/protocol.ts and the two Go files behind them and asserts
that every "always allow" kind the SDK offers has a label, that the methods are
spelled alike on both sides, and that the fields the card reads are the ones the
host sends — three failures that are all silent at runtime.
test/unit/toolCardReview.test.ts is the fifth, over the one click in the panel
no test here can perform: it reads media/chat.js, src/chatPanel.ts,
src/editDiff.ts and src/mentions.ts and asserts the button sits beside the
card's <details> rather than inside it, that the renderer offers a review only
where the extension said there was one, that the click hands back the call's
facts, that a pre-image is used only for the turn it came from, and that the
tools the review covers are the tools the change tracker follows — a second list
of file-writing tools is a list that goes stale in silence.
test/unit/replayWiring.test.ts is the sixth, over the replay: it reads
src/chatPanel.ts and media/chat.js and asserts the replay goes through
postEvent (so a replayed card is offered its review by the one place that
decides reviews) and never through onEvent (whose change-tracker snapshot would
file today's file contents under a historical call's id), that the reset lands
before the marker and the replay after both, that a host too old to answer costs
the tab a replay and nothing else, and that the two lines a replay adds are built
rather than written. The Go side of the same feature has its own reader tests,
including the one that asserts no entry and no line of the store file contains
image bytes or the API key.
The packaging step has its own check, since it is the part no unit test can
reach: npm run smoke:vsix extracts a built VSIX to a temporary directory,
asserts what is and is not inside it, verifies the binary against the manifest
it shipped with, and — on a package for this machine — runs that binary out of
the extracted tree and completes the handshake. A package that only works from
the directory it was built in fails it.