Zed Extension API: How to Embed Custom LSPs and Slash Commands into the Editor Using Rust WASM
When I first looked into editor extension development, my immediate reaction was "why is this so complicated?" VS Code extensions run on TypeScript over a Node.js runtime, which frequently leads to performance issues and process isolation limitations. Zed takes a completely different approach. All extensions are written in Rust, compiled to WebAssembly modules, and executed inside a sandboxed WASM runtime. This structurally prevents a single extension from dragging the editor process down into a crash (though it doesn't prevent responsiveness degradation from infinite loops or excessive CPU usage).
As of 2026, Zed's Extension API has reached a fairly mature stage. Extensions can now provide custom language server (LSP) integration, slash commands for the AI assistant panel, MCP (Model Context Protocol) server declarations, and DAP-based debugger adapters. This article focuses on LSP integration and slash commands, walking through how they actually work with real code.
What It Means to Communicate with the Editor over WASM
WIT: The Contract Between Extension and Editor
The part that felt most unfamiliar when I first saw Zed's extension architecture was WIT (WebAssembly Interface Type). It's an IDL designed specifically for the WASM Component Model — built to express value types, resource types, and interface inheritance, much like protobuf. It's neither linked at build time like a C header, nor tied exclusively to REST/JSON like OpenAPI. Think of it as a language that pins down the types and functions exchanged between a WASM module and its host (the editor) at the interface level.
Zed manages its Extension API through versioned WIT files, and wit-bindgen automatically generates Rust bindings from them. As a developer, you implement the zed::Extension trait exposed through these bindings.
This structure means the contract between extension and editor is verified at compile time. Passing the wrong type or calling a nonexistent function is caught at the build stage.
Compilation Target: The Switch to wasip2
Honestly, I stumbled quite a bit here. From zed_extension_api 0.5 onward, the wasm32-wasip2 target is required. Compiling with the older wasm32-wasip1 target produces errors related to __wasi_init_tp, which has been reported as an actual issue (#48724). wasip2 is WASI Preview 2 — built on the WebAssembly Component Model, providing more precise inter-module interfaces. Given that the Component Model is still being standardized at W3C, Zed made a relatively early bet on this direction.
Installing the target is straightforward.
rustup target add wasm32-wasip2Setting Up the Project Structure
The basic file layout for a single extension looks like this.
my-extension/
├── extension.toml # Metadata, LSP and slash command declarations
├── Cargo.toml
├── src/
│ └── lib.rs # Extension trait implementation
└── languages/
└── my-lang/
├── config.toml # Language definition
└── highlights.scm # Tree-sitter syntax highlighting queriesThe important things in Cargo.toml are setting the crate type to cdylib and adding zed_extension_api as a dependency.
[lib]
crate-type = ["cdylib"]
[dependencies]
zed_extension_api = "0.5"extension.toml declares how the editor should treat this extension. If you're providing both a language server and slash commands, it looks like this.
id = "my-extension"
name = "My Extension"
version = "0.1.0"
schema_version = 1
authors = ["Your Name <you@example.com>"]
description = "Custom LSP and slash commands"
[language_servers.my-lsp]
name = "My Language Server"
language = "my-lang"
[[slash_commands]]
name = "my-command"
description = "Does something useful"
requires_argument = falseCustom Language Server Integration
Implementing language_server_command
The heart of LSP integration is the language_server_command method. Zed uses the command this method returns to launch the external LSP process. The LSP binary itself runs natively on the user's system, outside of WASM — WASM's only job is to describe how to invoke it.
The code below is a conceptual example. The trait signatures (&mut self, &self), field names, and return types may vary by zed_extension_api version, so check the exact signatures on docs.rs before implementing.
// Conceptual example. Check docs.rs for the actual trait signatures per zed_extension_api version.
use zed_extension_api::{self as zed, LanguageServerId, Worktree, Command, Result};
struct MyExtension;
impl zed::Extension for MyExtension {
fn new() -> Self {
MyExtension
}
fn language_server_command(
&mut self,
_language_server_id: &LanguageServerId,
worktree: &Worktree,
) -> Result<Command> {
let binary_path = worktree
.which("my-lsp")
.ok_or("my-lsp binary not found in PATH")?;
Ok(Command {
command: binary_path,
args: vec!["--stdio".to_string()],
env: vec![],
})
}
}
zed::register_extension!(MyExtension);The reason some trait methods (e.g., language_server_command) take &mut self while slash command methods take &self is that WIT assigns different state-access requirements to each function. Since the actual signatures are determined by wit-bindgen-generated code, always verify which combination the latest API uses.
The BAML team's write-up on building a Zed extension for their custom DSL uses the same approach: find the binary path from the worktree, return it as a Command, and attach syntax highlighting via a Tree-sitter grammar.
The Pattern for Downloading an LSP Binary Directly
You can require users to install the LSP separately, but a common pattern is having the extension implement the download logic itself. The Zed Extension API provides a helper for downloading files from GitHub releases.
// Conceptual example (closer to pseudocode).
// Function names, field names, and enum variants vary by zed_extension_api version —
// check docs.rs for actual signatures and available options.
fn language_server_command(
&mut self,
_language_server_id: &LanguageServerId,
_worktree: &Worktree,
) -> Result<Command> {
// Field names may differ across versions, e.g. require_assets vs requires_assets,
// pre_release vs prerelease
let release = zed::latest_github_release(
"my-org/my-lsp",
zed::GithubReleaseOptions { /* see docs.rs */ },
)?;
let platform = zed::current_platform();
let asset_name = format!("my-lsp-{}-{}.tar.gz", release.version, platform);
let asset = release
.assets
.iter()
.find(|a| a.name == asset_name)
.ok_or_else(|| format!("no asset for platform: {platform}"))?;
// The download destination path is specified relative to the extension working directory.
// The host (Zed) maps this path to an isolated per-extension directory and resolves
// the actual filesystem path at process exec time. In other words, the relative path
// created inside WASM is not passed directly to the native command — the host prepends
// its own storage location before executing. See official docs for exact mapping rules.
let binary_path = format!("my-lsp-{}/bin/my-lsp", release.version);
if !std::fs::metadata(&binary_path).map_or(false, |m| m.is_file()) {
zed::download_file(
&asset.download_url,
&binary_path,
zed::DownloadedFileType::GzipTar,
)
.map_err(|e| format!("failed to download: {e}"))?;
}
Ok(Command {
command: binary_path,
args: vec!["--stdio".to_string()],
env: vec![],
})
}Implementing Slash Commands
Slash commands are custom commands invoked as /command-name in the Zed AI assistant panel. run_slash_command defines the execution behavior, and complete_slash_command_argument defines autocompletion.
Basic Structure
The code below is also a conceptual example. In particular, the actual field layout of SlashCommandOutputSection (e.g., whether an icon field or icon enum variants exist) differs across versions — always check the current fields in the SlashCommandOutputSection docs before compiling.
// Conceptual example. Verify the actual field layout of SlashCommandOutputSection
// on docs.rs and adjust accordingly.
use zed_extension_api::{
self as zed, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
Worktree, Result,
};
impl zed::Extension for MyExtension {
// ... new, language_server_command, etc.
fn run_slash_command(
&self,
command: SlashCommand,
args: Vec<String>,
_worktree: Option<&Worktree>,
) -> Result<SlashCommandOutput, String> {
match command.name.as_str() {
"my-command" => {
let label = args.first().cloned().unwrap_or_default();
let text = format!("# {label}\n\nContext content goes here.");
Ok(SlashCommandOutput {
sections: vec![SlashCommandOutputSection {
range: (0..label.len()).into(),
label: label.clone(),
// Icon-related fields vary by API version in presence, name, and type —
// check docs.rs and populate only the fields that exist.
}],
text,
})
}
_ => Err(format!("unknown command: {}", command.name)),
}
}
fn complete_slash_command_argument(
&self,
command: SlashCommand,
_args: Vec<String>,
) -> Result<Vec<zed::SlashCommandArgumentCompletion>, String> {
match command.name.as_str() {
"my-command" => Ok(vec![
zed::SlashCommandArgumentCompletion {
label: "option-one".to_string(),
new_text: "option-one".to_string(),
run_command: true,
},
zed::SlashCommandArgumentCompletion {
label: "option-two".to_string(),
new_text: "option-two".to_string(),
run_command: true,
},
]),
_ => Err(format!("unknown command: {}", command.name)),
}
}
}The official Zed repository's slash-commands-example includes two reference implementations: echo (returns input as-is) and pick-one (provides selectable options with autocompletion). Reading that code directly is the fastest way to get started.
The Pattern for Calling External APIs
There are patterns like a /jira command that fetches a JIRA issue and inserts it into the AI context. However, network access from within the WASM environment is limited to what the host permits, and you'll use the HTTP client provided by the Extension API. When implementing this, check the official docs for the allowed network access methods and their constraints.
Building and Local Testing
Building is straightforward.
cargo build --release --target wasm32-wasip2For local testing, you can register the extension path directly in Zed's settings file (~/.config/zed/settings.json).
{
"dev_extensions": ["/path/to/my-extension"]
}For distribution packaging, you bundle extension.toml, the compiled WASM, and optional Tree-sitter grammar files for submission. See the Developing Extensions docs for the exact package format and submission process.
If your extension includes a Tree-sitter parser, you'll need wasi-sdk as an additional tool, since C/C++-based parsers must be cross-compiled to WASM. Zed can download it automatically, or you can specify the path via the WASI_SDK_PATH environment variable.
Trade-offs: Know Where You'll Hit Walls
| Item | Strengths | Practical Limitations |
|---|---|---|
| Security / Isolation | WASM sandbox prevents extension crashes from propagating to the editor | Debugging tools inside WASM modules are still limited; logging and Zed's extension error panel are the most practical options in practice |
| Type Safety | WIT + Rust combination validates the contract at compile time | Binding regeneration required when the WIT API changes |
| Cross-platform | A single WASM handles macOS, Linux, and Windows | LSP binaries still require separate native binaries per platform |
| API Surface | Stable core API provided | Accessible editor features are narrower than the VS Code Extension API; UI manipulation APIs are planned for expansion on the roadmap |
| Ecosystem | WIT-based design leaves room for future extension | Still in early ecosystem stages; gaps exist for certain languages and toolchains |
| Build Complexity | The build command itself is simple | Requires wasm32-wasip2 target setup, wasi-sdk installation (when including Tree-sitter), and build pipeline configuration |
Common Pitfalls
wasip1 → wasip2 migration: If you're maintaining an extension that previously built with wasm32-wasip1, you'll need to both change the target and verify API compatibility. If you see a __wasi_init_tp error, check the target first.
LSP binary distribution responsibility: WASM only describes how to invoke the LSP — the actual LSP process runs natively on the user's system. That means the LSP binary must be present on the user's machine. If your extension doesn't implement download logic, make sure your README clearly explains how to install it.
License file: Distributing an extension requires a license file. Check the exact requirements in the Zed official contribution guide and extension docs each time — it's an easy item to miss when setting up the repository.
Why Zed's Extension API Is Interesting Right Now
Zed built its built-in debugger on DAP and has opened an API for extensions to provide DAP adapters (see the relevant posts on the Zed official blog for exact release timing and details). It's also interesting that declaring an MCP server in extension.toml integrates directly with Zed's AI features. The official roadmap includes plans to expand the API so extensions can customize the Zed UI itself — building extension development experience now positions you to take advantage of a much wider set of possibilities down the road.
Here's a summary of what's extensible today and what's coming:
- Stable and available now: LSP integration, slash commands, Tree-sitter grammar support
- Relatively recently added: MCP server declarations (AI assistant integration), DAP debugger adapters
- Announced on the roadmap: UI customization API
If you're already writing Rust, the entry cost for the build pipeline is lower than you might expect. Add zed_extension_api to Cargo.toml, install the wasm32-wasip2 target, implement the Extension trait — and the skeleton is done. If Zed support for a particular language or tool doesn't exist yet, there's plenty of room to build it yourself and contribute to the ecosystem.
A Recommended Order for Getting Started
If you're just starting out, here's the approach I'd recommend: clone the official slash-commands-example as-is and attach it via dev_extensions → compare the trait signatures and return types against docs.rs one by one while adapting it into a command for your own domain → then move on to LSP integration. That path lets you naturally learn the API surface. Documentation and actual API signatures sometimes diverge, so always treat the latest zed_extension_api docs.rs as your source of truth.
References
- Life of a Zed Extension: Rust, WIT, Wasm — Zed Official Blog
- Developing Extensions — Zed Official Docs
- Slash Command Extensions — Zed Official Docs
- Language Extensions — Zed Official Docs
- slash-commands-example — GitHub (zed-industries/zed)
- SlashCommand in zed_extension_api — docs.rs
- zed_extension_api — lib.rs
- How to write a Zed extension for a made up language — BAML Blog
- Extensions System — DeepWiki (zed-industries/zed)
- wasm32-wasip2 compilation error issue #48724 — GitHub