Run assistant-ui in a VS Code extension webview, with your route handlers in the extension host behind a fetch bridge.
assistant-ui works in a VS Code extension through @assistant-ui/react and @assistant-ui/vscode. The webview is a React DOM environment, so the components and runtimes are the ones you use on the web. @assistant-ui/vscode supplies the part VS Code does not: a way for the webview to reach your backend.
Warning
Never put a provider API key in webview code. Anyone can read the webview's bundle from the installed extension. Keep the key in the extension host, in context.secrets or the host's environment.
How it works
A webview has no server behind it. Its origin is vscode-webview://, its Content Security Policy only allows resources from the extension, and a relative fetch("/api/chat") reaches nothing. @assistant-ui/vscode tunnels fetch over the webview's postMessage channel instead.
- In the webview,
vscodeFetchhas the signature offetch. It sends the request over the channel and returns aResponsewhose body streams as the host writes it. - In the extension host,
serveWebviewHostrebuilds a standardRequest, calls the route handler for its path and method, and streams theResponseback. Stopping a run in the webview abortsreq.signalin the handler, and so does reloading the webview: the new page tells the host to abort the requests the old page left running.
The extension host runs Node.js, which has the web Request and Response classes. A route handler written for Next.js, such as export async function POST(req: Request), therefore runs in the extension host without changes.
Quick start
Scaffold the vscode template:
npx assistant-ui@latest create my-extension -t vscode
cd my-extension
code .Press F5. The dev task builds the extension in watch mode, and an Extension Development Host window opens with the Assistant view in the secondary side bar. The first time the view opens, it prompts you to set your OpenAI API key, which it stores in VS Code's secret storage. Run Assistant: Set OpenAI API Key from the Command Palette to change it.
| File | Runs in | Contents |
|---|---|---|
src/extension.ts | Extension host | Registers the webview view, serves its routes, and writes its HTML |
src/api/chat/route.ts | Extension host | The AI SDK chat route, the same handler as in the Next.js default template |
src/webview/assistant.tsx | Webview | useChatRuntime over vscodeFetch, and the Thread component |
src/webview/app.css | Webview | Tailwind and the VS Code theme |
scripts/build.mts | Build | esbuild for the host and the webview, and Tailwind for the CSS |
npm run package builds a .vsix with @vscode/vsce once you set your publisher in package.json.
Add to an existing extension
1. Install the packages
npm install @assistant-ui/vscode @assistant-ui/react @assistant-ui/core @assistant-ui/ai-sdk ai @ai-sdk/openai zod react react-dom
npm install -D esbuild postcss tailwindcss @tailwindcss/postcss tw-animate-css @types/react @types/react-domAdd the assistant-ui components with the CLI or copy them from the template.
2. Serve the webview from the extension host
serveWebviewHost answers the webview's requests with your routes, opens the links the webview forwards, and backs its storage. renderWebviewHtml writes the page with a Content Security Policy, a nonce on each script, and your bundle's URIs converted for the webview.
import { renderWebviewHtml, serveWebviewHost } from "@assistant-ui/vscode/host";
import * as vscode from "vscode";
import { POST } from "./api/chat/route";
export function activate(context: vscode.ExtensionContext) {
const webviewDir = vscode.Uri.joinPath(context.extensionUri, "dist", "webview");
const provider: vscode.WebviewViewProvider = {
resolveWebviewView(view) {
view.webview.options = {
enableScripts: true,
localResourceRoots: [webviewDir],
};
const host = serveWebviewHost(view.webview, {
routes: { "/api/chat": { POST } },
// VS Code opens a string as is but re-encodes a Uri, which corrupts escaped characters.
openExternal: (url) => vscode.env.openExternal(url as unknown as vscode.Uri),
storage: context.globalState,
});
view.onDidDispose(() => host.dispose());
view.webview.html = renderWebviewHtml(view.webview, {
scripts: [vscode.Uri.joinPath(webviewDir, "main.js")],
styles: [
vscode.Uri.joinPath(webviewDir, "app.css"),
vscode.Uri.joinPath(webviewDir, "main.css"),
],
title: "Assistant",
surface: "sidebar",
scriptType: "classic",
});
},
};
context.subscriptions.push(
vscode.window.registerWebviewViewProvider("myExtension.chat", provider),
);
}The provider fills a view that your package.json declares. Pass the id of a webview view your extension already contributes, or add one:
{
"contributes": {
"views": {
"explorer": [
{ "type": "webview", "id": "myExtension.chat", "name": "Assistant" }
]
}
}
}Set localResourceRoots to the folder that holds the bundle, because the webview cannot load files outside it.
3. Mount the runtime in the webview
Pass vscodeFetch as the transport's fetch. Import ./zod-jitless before anything else, and call installLinkInterceptor() so links open in the browser.
import { config } from "zod";
// zod probes `new Function` when it builds its first object schema, which a
// strict CSP reports as a script-src violation even though zod catches it.
config({ jitless: true });import "./zod-jitless";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/ai-sdk";
import { installLinkInterceptor, vscodeFetch } from "@assistant-ui/vscode/webview";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { createRoot } from "react-dom/client";
import { Thread } from "@/components/assistant-ui/elements/thread.aui";
installLinkInterceptor();
const Assistant = () => {
const runtime = useChatRuntime({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
transport: new AssistantChatTransport({
api: "/api/chat",
fetch: vscodeFetch,
}),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<main className="h-screen">
<Thread />
</main>
</AssistantRuntimeProvider>
);
};
const root = document.getElementById("root");
if (root) createRoot(root).render(<Assistant />);sendAutomaticallyWhen continues the run after a frontend tool, such as an approval, adds its result.
4. Bundle both sides
The extension host loads a CommonJS bundle that leaves vscode external. The webview loads one classic script, which carries the nonce renderWebviewHtml puts on the script tags it writes. React reads process.env.NODE_ENV, which a webview does not define, so the bundler has to replace it.
import { readFile, writeFile } from "node:fs/promises";
import tailwindcss from "@tailwindcss/postcss";
import { build } from "esbuild";
import postcss from "postcss";
await build({
entryPoints: ["src/extension.ts"],
outfile: "dist/extension.js",
bundle: true,
format: "cjs",
platform: "node",
target: "node22",
external: ["vscode"],
});
await build({
entryPoints: ["src/webview/main.tsx"],
outfile: "dist/webview/main.js",
bundle: true,
format: "iife",
platform: "browser",
target: "chrome120",
splitting: false,
jsx: "automatic",
define: { "process.env.NODE_ENV": JSON.stringify("production") },
});
const css = await postcss([tailwindcss]).process(
await readFile("src/webview/app.css", "utf-8"),
{ from: "src/webview/app.css", to: "dist/webview/app.css" },
);
await writeFile("dist/webview/app.css", css.css);esbuild writes the CSS that packages import, such as the Markdown styles, next to the bundle as dist/webview/main.css, which is why styles lists it. The last step compiles app.css with Tailwind. The template's build script does the same and adds a watch mode for F5.
A single bundle also inlines everything a component can load lazily. The Shiki highlighter is the large case: it loads each grammar on demand, so a single bundle carries every grammar and theme, about 15 MB. To keep the webview small, pass a highlighter built with only the languages you need, or build the webview as ESM with code splitting and keep renderWebviewHtml's default module script type. A module script's imports, import() included, inherit its nonce, so the chunks should load without a scriptSrc entry. The VS Code test bed only runs a single bundle, so check for CSP errors in Developer: Open Webview Developer Tools after you switch.
5. Follow the VS Code theme
@assistant-ui/vscode/theme.css maps the shadcn tokens the components use to VS Code's --vscode-* variables, so the view restyles itself when the user switches colour theme. Import it after Tailwind:
@import "tailwindcss";
@import "tw-animate-css";
@import "@assistant-ui/vscode/theme.css";If you keep an existing globals.css from an assistant-ui template, import the theme at the end of the file instead, so its dark variant and fonts replace the template's. Set surface in renderWebviewHtml to "editor" for a webview panel in an editor tab, or to "panel" for a view in the bottom panel.
Content Security Policy
renderWebviewHtml applies a strict policy by default: scripts and inline styles need the page's nonce, stylesheets, fonts and images load only from the extension's own resources (plus data: and blob:), frames are blocked, and connect-src only allows the extension's resources too. The bridge needs no connect-src entry, because it does not use the network.
- Remote images and media are blocked, because an image URL in model output loads as soon as the message renders and can carry chat data away.
imgSrcandmediaSrcallow the hosts you trust, such ashttps://icons.duckduckgo.comfor the favicons of theSourcescomponent. csp: "relaxed"allows inline styles, for libraries that inject<style>tags without a nonce.connectSrc,frameSrc, andscriptSrcappend sources, one host or scheme per entry, andwasmUnsafeEval: trueallows WebAssembly.'unsafe-eval'is never added.getCspNonce()from@assistant-ui/vscode/webviewreturns the nonce for a library that injects tags at runtime.
The AI SDK depends on zod, and zod's first object schema probes new Function. The strict policy reports that as a violation even though zod recovers, which is why the webview enables zod's jitless mode before anything imports it.
Runtimes
| Runtime | Webview | Extension host route |
|---|---|---|
| AI SDK | useChatRuntime with AssistantChatTransport({ fetch: vscodeFetch }) | An AI SDK route handler that returns toUIMessageStreamResponse() |
useDataStreamRuntime and useAssistantTransportRuntime call the global fetch and take no fetch option, so they cannot reach the extension host through the bridge.
Thread persistence
createVSCodeStorage() stores threads in the Memento you pass as storage to serveWebviewHost, such as context.globalState, so they survive reloading the window. Its keys are prefixed with storagePrefix ("@assistant-ui/vscode:" by default).
import { createLocalStorageAdapter } from "@assistant-ui/core/react";
import { useRemoteThreadListRuntime } from "@assistant-ui/react";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/ai-sdk";
import { createVSCodeStorage, vscodeFetch } from "@assistant-ui/vscode/webview";
const threads = createLocalStorageAdapter({ storage: createVSCodeStorage() });
const transport = new AssistantChatTransport({ api: "/api/chat", fetch: vscodeFetch });
export function useAssistantRuntime() {
return useRemoteThreadListRuntime({
adapter: threads,
runtimeHook: function useThreadRuntime() {
return useChatRuntime({ transport });
},
});
}A Memento is not encrypted, so keep secrets in context.secrets.
What the webview can ask of the host
The webview renders model output, so treat what it sends like input from a browser. @assistant-ui/vscode/host checks every message before acting on it:
- A request reaches only the routes you pass, by path and method. A URL on an
http:orhttps:origin other than the bridge's own is refused, and so is anything theRequestconstructor rejects. openExternalreceives only URLs whose scheme is inexternalSchemes(http:,https:andmailto:by default). Keepcommand:,vscode:andfile:out of that list, because a link in model output could then run a command or open a local file.createVSCodeStoragereads and writes only string values understoragePrefix.
Your route handlers decide everything else, so validate request bodies there as you would in a web server.
Links, clipboard, and limitations
- Links. A webview cannot navigate away or open windows.
installLinkInterceptor()sends clicks onhttp:,https:, andmailto:links, andwindow.opencalls, to the host'sopenExternal, which checks the scheme again before opening the URL. - Clipboard. The copy buttons write with
navigator.clipboard.writeText, which VS Code allows in webviews. - Downloads. A webview cannot save files. An action that downloads a file, such as exporting a thread as Markdown, needs a route that saves it from the extension host.
- Speech. The Web Speech API is not available in a webview, so
WebSpeechSynthesisAdapterandWebSpeechDictationAdapterdo not work. Use an adapter that calls a speech service through a route. - Drag and drop. VS Code handles files dragged over its window. Hold Shift while dropping a file to drop it on the composer.
- Hidden views. VS Code destroys a webview's page when its view is hidden, which drops in-memory threads and interrupts a streaming reply. Pass
webviewOptions: { retainContextWhenHidden: true }toregisterWebviewViewProviderto keep the page alive at the cost of memory, as the template does, or persist threads withcreateVSCodeStorage.
Testing
examples/with-vscode is the test bed for this integration. It answers with scripted fixtures, so it needs no network or API key, and its Readiness view runs probes against the live webview: the fetch bridge, aborts, frontend tools, the Content Security Policy, the theme, links, thread persistence, and every rich fixture. From the repository root:
pnpm turbo build --filter='with-vscode^...'
pnpm --filter with-vscode probesThe test launches VS Code through @vscode/test-electron and prints a table of probe results. On Linux, run it under xvfb-run -a.