# VS Code
URL: /docs/guides/vscode

Run assistant-ui in a VS Code extension webview, with your route handlers in the extension host behind a fetch bridge.

> For AI agents: a documentation index is available at [llms.txt](/llms.txt). Use `.md` for canonical markdown pages; `.mdx` is kept as a backwards-compatible alias on supported URL paths.

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.

> [!warn]
>
> 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, `vscodeFetch` has the signature of `fetch`. It sends the request over the channel and returns a `Response` whose body streams as the host writes it.
- In the extension host, `serveWebviewHost` rebuilds a standard `Request`, calls the route handler for its path and method, and streams the `Response` back. Stopping a run in the webview aborts `req.signal` in 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-dom
```

Add the assistant-ui components with the [CLI](/docs/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. `imgSrc` and `mediaSrc` allow the hosts you trust, such as `https://icons.duckduckgo.com` for the favicons of the `Sources` component.
- `csp: "relaxed"` allows inline styles, for libraries that inject `<style>` tags without a nonce.
- `connectSrc`, `frameSrc`, and `scriptSrc` append sources, one host or scheme per entry, and `wasmUnsafeEval: true` allows WebAssembly. `'unsafe-eval'` is never added.
- `getCspNonce()` from `@assistant-ui/vscode/webview` returns 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](/docs/runtimes/ai-sdk/v7) | `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:` or `https:` origin other than the bridge's own is refused, and so is anything the `Request` constructor rejects.
- `openExternal` receives only URLs whose scheme is in `externalSchemes` (`http:`, `https:` and `mailto:` by default). Keep `command:`, `vscode:` and `file:` out of that list, because a link in model output could then run a command or open a local file.
- `createVSCodeStorage` reads and writes only string values under `storagePrefix`.

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 on `http:`, `https:`, and `mailto:` links, and `window.open` calls, to the host's `openExternal`, 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 `WebSpeechSynthesisAdapter` and `WebSpeechDictationAdapter` do 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 }` to `registerWebviewViewProvider` to keep the page alive at the cost of memory, as the template does, or persist threads with `createVSCodeStorage`.

## Testing

[`examples/with-vscode`](https://github.com/assistant-ui/assistant-ui/tree/main/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 probes
```

The test launches VS Code through `@vscode/test-electron` and prints a table of probe results. On Linux, run it under `xvfb-run -a`.