Every time GitHub Copilot or Cursor shows you gray “ghost text” trailing after your cursor, a specific piece of the VS Code extension API is doing the work: the Inline Completion Provider. It is a different API from the Chat Participant surface that powers conversational AI panels, and different again from the classic CompletionItemProvider that drives IntelliSense dropdowns. If you have ever wanted to build your own AI-powered autocomplete, wire a local model into your editor, or simply understand how Copilot’s suggestion engine actually works under the hood, this is the API you need to learn.
This tutorial builds a complete, working VS Code extension from scratch, in 13 steps. By the end you will have an extension that debounces keystrokes, calls an LLM API, caches responses, renders single- and multi-line ghost text, stores API keys securely, and ships as a packaged .vsix file ready for the Marketplace or Open VSX. As of October 2026, VS Code sits at version 1.140 (released September 30, 2026), with 1.141 already circulating in the Insiders channel, and the @types/vscode type definitions package most recently published as 1.138.0. Those are the versions this guide targets.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What a VS Code Inline Completion Provider Extension Actually Does
An inline completion provider is a registered callback that VS Code invokes whenever it thinks you might want a suggestion: after you type a character, move the cursor, or explicitly trigger a completion with a keybinding. The callback receives the current document, the cursor position, a trigger context, and a cancellation token, and it returns either an array of InlineCompletionItem objects or an InlineCompletionList. VS Code then renders the first item as translucent “ghost text” immediately after the cursor. Pressing Tab accepts it; pressing Escape or continuing to type dismisses it.
This is structurally different from three other extension points that often get confused with it. A CompletionItemProvider populates the dropdown list you see when you type a dot after an object (IntelliSense) and requires the user to pick an item from a menu. The Chat Participant API, registered with vscode.chat.createChatParticipant, powers the conversational sidebar where you type @participant and get a multi-turn exchange. The Language Model Tool API lets a chat participant or agent invoke external functions mid-conversation. Inline completion sits apart from all three: it is silent, ambient, and designed to feel like the editor is finishing your sentence rather than answering a question.
| VS Code API | Registration Call | User Interaction | Typical Use Case | Latency Budget |
|---|---|---|---|---|
| Inline Completion Provider | registerInlineCompletionItemProvider | Silent ghost text, Tab to accept | Copilot-style autocomplete | Under 300ms |
| Completion Item Provider | registerCompletionItemProvider | Dropdown menu, Enter to select | IntelliSense, snippets | Under 150ms |
| Chat Participant | chat.createChatParticipant | Typed @mention, streamed reply | Conversational AI assistants | 1-10s (streamed) |
| Language Model Tool | lm.registerTool | Invoked by an agent, not directly by the user | Agent function calling | Varies by tool |
That latency budget matters more for inline completion than for almost any other extension type. A chat response can take several seconds and feel acceptable because the user asked a question and expects to wait. A ghost-text suggestion that lags behind typing by even half a second feels broken, because it is competing with the user’s own typing speed. Most of this tutorial’s engineering effort, debouncing, caching, and cancellation, exists specifically to keep that latency low.
Prerequisites: Versions and Tools You Need Before Starting
You do not need a large toolchain for this project, but you do need the right versions. VS Code’s extension host runs on Node.js internally, and your build tooling needs a reasonably current Node release to avoid dependency warnings from newer npm packages. The table below lists what this tutorial was built and tested against.
| Tool | Version Used Here | Why It Matters |
|---|---|---|
| Visual Studio Code | 1.140 (stable, Sept 30, 2026) | Target editor; inline completion API is stable since 1.68 but behavior has been refined in every release since |
| @types/vscode | 1.138.0 | TypeScript type definitions matching the extension API surface |
| Node.js | 20.x or newer LTS | Required by the VS Code extension generator and build scripts |
| TypeScript | latest stable (5.x line) | Extension source is written in TypeScript, then compiled to JavaScript |
| yo + generator-code | latest npm versions | Official Microsoft scaffolding tool for new extensions |
| @vscode/vsce | latest npm version | Packages your extension into a distributable .vsix file |
| ovsx | latest npm version | Publishes the same .vsix to Open VSX, used by Cursor, VSCodium, and Theia |
You will also need an API key for whichever language model you plan to call for completions. This tutorial uses a generic fetch call written against an OpenAI-compatible chat completions endpoint, which means it works with minimal changes against most hosted model providers or a local server running something like Ollama or LM Studio. Nothing in the extension code is tied to one vendor.
Step 1: Scaffold the Extension With the Yeoman Generator
Microsoft maintains an official Yeoman generator that produces a working extension skeleton with the correct package.json contribution points, a TypeScript build configuration, and a linting setup already wired together. Install it globally and run it once.
npm install -g yo generator-code
yo code
# When prompted, choose:
# ? What type of extension do you want to create? New Extension (TypeScript)
# ? What's the name of your extension? inline-ghost-complete
# ? Bundle the source code with webpack? Yes
# ? Initialize a git repository? Yes
# ? Which package manager to use? npm
cd inline-ghost-complete
npm install
Open the resulting folder in VS Code and press F5. This launches an Extension Development Host, a second VS Code window with your in-progress extension loaded. Every time you want to test a change, you will reload this window with Ctrl+R (Cmd+R on macOS) rather than restarting the whole debug session. Keep this window open for the rest of the tutorial; you will use it constantly.
Step 2: Register Your InlineCompletionItemProvider
Open src/extension.ts and replace the generated placeholder command with a provider registration. The registration call takes a document selector, which tells VS Code which files your provider should run against, and an object implementing provideInlineCompletionItems.
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
const provider: vscode.InlineCompletionItemProvider = {
async provideInlineCompletionItems(document, position, ctx, token) {
// Steps 3-6 fill this in. For now, return nothing.
return { items: [] };
}
};
const selector: vscode.DocumentSelector = [
{ scheme: 'file', language: 'javascript' },
{ scheme: 'file', language: 'typescript' },
{ scheme: 'file', language: 'python' }
];
context.subscriptions.push(
vscode.languages.registerInlineCompletionItemProvider(selector, provider)
);
}
export function deactivate() {}
Reload the Extension Development Host and open any JavaScript file inside it. Nothing visible happens yet, which is correct: an empty items array tells VS Code there is no suggestion. Confirming that the provider loads without throwing is the real milestone here. Check the Debug Console in your main VS Code window for errors before moving on.
Step 3: Build a Debounced Request Pipeline
VS Code calls provideInlineCompletionItems far more often than you would expect, on effectively every keystroke in some cases. Calling an LLM API on every keystroke would be slow, expensive, and would flood the model with near-duplicate requests. The fix is a debounce: wait a short, fixed delay after the last keystroke before firing the request, and cancel the pending request entirely if the user types again before the delay elapses.
let debounceTimer: NodeJS.Timeout | undefined;
const DEBOUNCE_MS = 250;
function debouncedCompletion(
fn: () => Promise<vscode.InlineCompletionItem[]>,
token: vscode.CancellationToken
): Promise<vscode.InlineCompletionItem[]> {
return new Promise((resolve) => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (token.isCancellationRequested) {
resolve([]);
return;
}
const result = await fn();
resolve(result);
}, DEBOUNCE_MS);
token.onCancellationRequested(() => {
if (debounceTimer) clearTimeout(debounceTimer);
resolve([]);
});
});
}
A delay of 200-300ms is a reasonable starting point. Shorter feels snappier but burns more API calls on partial, throwaway keystrokes. Longer saves money but starts to feel laggy compared to Copilot, which most engineers are now calibrated against. Treat this number as a setting you tune later with real usage data, not a constant you set once and forget.
Step 4: Call an LLM API and Parse the Completion Text
With debouncing in place, write the function that actually requests a completion. Pull the text before the cursor (the “prefix”) and a reasonable window of text after it (the “suffix”) and send both to the model with instructions to continue the prefix without repeating it.
interface CompletionRequest {
prefix: string;
suffix: string;
languageId: string;
}
async function fetchCompletion(
req: CompletionRequest,
apiKey: string,
signal: AbortSignal
): Promise<string> {
const response = await fetch('https://api.your-model-provider.com/v1/chat/completions', {
method: 'POST',
signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: 'your-code-completion-model',
max_tokens: 80,
temperature: 0.2,
messages: [
{
role: 'system',
content: `Continue the ${req.languageId} code. Return only the ` +
`continuation text, no markdown fences, no explanation.`
},
{
role: 'user',
content: `PREFIX:\n${req.prefix}\n\nSUFFIX:\n${req.suffix}`
}
]
})
});
if (!response.ok) {
throw new Error(`Completion API returned ${response.status}`);
}
const data = await response.json();
return data.choices?.[0]?.message?.content?.trim() ?? '';
}
Keep max_tokens low. Inline completions are meant to finish a line or a short block, not generate an entire function body unprompted; a tight token ceiling also keeps latency and cost down. The temperature of 0.2 favors predictable, low-variance output, which matters more for code than for conversational text because wildly creative code completions are usually wrong code completions.
Step 5: Render Ghost Text for Single- and Multi-Line Suggestions
Now connect steps 3 and 4 back into the provider and wrap the raw string in an InlineCompletionItem. VS Code renders whatever you put in insertText as ghost text starting exactly at the position you specify, and it correctly handles multi-line strings as long as you include the newline characters.
const provider: vscode.InlineCompletionItemProvider = {
async provideInlineCompletionItems(document, position, ctx, token) {
const prefix = document.getText(
new vscode.Range(new vscode.Position(0, 0), position)
);
const suffix = document.getText(
new vscode.Range(position, document.lineAt(document.lineCount - 1).range.end)
);
const items = await debouncedCompletion(async () => {
try {
const text = await fetchCompletion(
{ prefix, suffix, languageId: document.languageId },
await getApiKey(),
new AbortController().signal
);
if (!text) return [];
return [new vscode.InlineCompletionItem(
text,
new vscode.Range(position, position)
)];
} catch (err) {
console.error('Completion request failed:', err);
return [];
}
}, token);
return { items };
}
};
Test this against both a single-line case, finishing a variable declaration, and a multi-line case, completing a loop body. Multi-line ghost text renders correctly in modern VS Code, but it is worth confirming visually in your Extension Development Host rather than assuming, since indentation handling has shifted slightly across past releases.
Step 6: Cancel Stale Requests With CancellationToken
The debounce timer handles rapid keystrokes, but it does not handle the case where a request is already in flight to the API when the user types again. Without proper cancellation, you can end up rendering a stale completion for text the user has already moved past. Thread the cancellation token through to the actual network call using an AbortController tied to it.
async function provideInlineCompletionItems(document, position, ctx, token) {
const controller = new AbortController();
token.onCancellationRequested(() => controller.abort());
const prefix = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
const suffix = document.getText(new vscode.Range(position, document.lineAt(document.lineCount - 1).range.end));
try {
const items = await debouncedCompletion(async () => {
const text = await fetchCompletion(
{ prefix, suffix, languageId: document.languageId },
await getApiKey(),
controller.signal
);
return text ? [new vscode.InlineCompletionItem(text, new vscode.Range(position, position))] : [];
}, token);
return { items };
} catch (err) {
if (controller.signal.aborted) return { items: [] };
console.error(err);
return { items: [] };
}
}
This is one of the parts of the API that is easy to skip during a quick prototype and expensive to skip in production. Without it, fast typists generate a growing backlog of in-flight requests that complete out of order, occasionally flashing an old, now-irrelevant suggestion onto the screen a half second after it stopped being relevant.
Step 7: Cache Completions to Cut Latency and Token Cost
Developers frequently retype or revert small edits, undo and redo, or pause mid-thought and return to the same cursor position. A simple in-memory cache keyed on the prefix text avoids re-querying the model for a prompt you have already seen in the current session.
class CompletionCache {
private cache = new Map<string, { text: string; timestamp: number }>();
private readonly maxEntries = 200;
private readonly ttlMs = 5 * 60 * 1000;
private keyFor(prefix: string): string {
// Only the trailing 500 chars matter for most completions,
// and hashing keeps memory use flat regardless of file size.
return prefix.slice(-500);
}
get(prefix: string): string | undefined {
const entry = this.cache.get(this.keyFor(prefix));
if (!entry) return undefined;
if (Date.now() - entry.timestamp > this.ttlMs) {
this.cache.delete(this.keyFor(prefix));
return undefined;
}
return entry.text;
}
set(prefix: string, text: string): void {
if (this.cache.size >= this.maxEntries) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey) this.cache.delete(oldestKey);
}
this.cache.set(this.keyFor(prefix), { text, timestamp: Date.now() });
}
}
const completionCache = new CompletionCache();
Check the cache before calling fetchCompletion, and populate it after a successful response. A five-minute TTL balances staleness against hit rate reasonably well for most coding sessions; shrink it if your model is sensitive to broader file context changing around the cached prefix.
Step 8: Store API Keys Safely With SecretStorage
Never store an API key in settings.json or a workspace configuration file. Both are plain text and both are commonly committed to git by accident. VS Code’s SecretStorage API, available on every ExtensionContext, delegates to the operating system’s credential store (Keychain on macOS, Credential Manager on Windows, libsecret on Linux) and keeps the key out of any file a user might sync or commit.
async function getApiKey(context: vscode.ExtensionContext): Promise<string> {
const existing = await context.secrets.get('inlineGhostComplete.apiKey');
if (existing) return existing;
const entered = await vscode.window.showInputBox({
prompt: 'Enter your completion model API key',
password: true,
ignoreFocusOut: true
});
if (!entered) {
throw new Error('API key is required to generate completions.');
}
await context.secrets.store('inlineGhostComplete.apiKey', entered);
return entered;
}
// Register a command so users can rotate the key later
context.subscriptions.push(
vscode.commands.registerCommand('inlineGhostComplete.setApiKey', async () => {
const key = await vscode.window.showInputBox({ password: true, ignoreFocusOut: true });
if (key) {
await context.secrets.store('inlineGhostComplete.apiKey', key);
vscode.window.showInformationMessage('API key saved.');
}
})
);
Register that command in package.json under contributes.commands so it is discoverable from the Command Palette. Anyone reviewing your extension’s security posture, including Marketplace reviewers, will specifically check whether credentials flow through SecretStorage rather than plain configuration.
Step 9: Add a Status Bar Toggle and User Settings
Users need an obvious way to turn the extension off, both for debugging and because constant ghost text is not always welcome, for example while live-coding on a shared screen. A status bar item doubles as a visual indicator and a one-click toggle.
let enabled = true;
const statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
statusItem.command = 'inlineGhostComplete.toggle';
statusItem.text = '$(sparkle) Ghost Complete: On';
statusItem.show();
context.subscriptions.push(
vscode.commands.registerCommand('inlineGhostComplete.toggle', () => {
enabled = !enabled;
statusItem.text = enabled ? '$(sparkle) Ghost Complete: On' : '$(circle-slash) Ghost Complete: Off';
vscode.window.showInformationMessage(`Ghost Complete is now ${enabled ? 'on' : 'off'}.`);
})
);
Inside provideInlineCompletionItems, return { items: [] } immediately whenever enabled is false, before doing any of the debounce or network work. Also expose a configurable debounce delay and maximum token count through contributes.configuration in package.json, so power users can tune behavior without editing your source.
Step 10: Test the Extension With @vscode/test-electron
Manual testing in the Extension Development Host catches obvious breakage, but it does not scale and does not run in CI. Microsoft’s @vscode/test-electron package launches a real, disposable VS Code instance and runs Mocha-based tests against your activated extension.
npm install --save-dev @vscode/test-electron mocha @types/mocha
// src/test/suite/extension.test.ts
import * as assert from 'assert';
import * as vscode from 'vscode';
suite('Inline Ghost Complete', () => {
test('provider registers without throwing', async () => {
const ext = vscode.extensions.getExtension('your-publisher.inline-ghost-complete');
assert.ok(ext, 'Extension should be discoverable');
await ext?.activate();
assert.strictEqual(ext?.isActive, true);
});
test('cache returns undefined for unseen prefix', () => {
// Import and test CompletionCache in isolation here,
// independent of the live network call.
});
});
Keep the network-calling logic (fetchCompletion) separate from the VS Code-specific glue code (the provider registration itself). That separation lets you unit test the API call and the cache with plain Mocha or Jest, reserving the heavier @vscode/test-electron suite for integration checks that genuinely need a running editor instance.
Step 11: Package With vsce and Install the .vsix Locally
Before publishing anywhere, package the extension into a .vsix file and install it in a normal VS Code window, not the Extension Development Host, to confirm it behaves correctly outside the debugger.
npm install -g @vscode/vsce
vsce package
# Produces: inline-ghost-complete-0.0.1.vsix
code --install-extension inline-ghost-complete-0.0.1.vsix
Restart VS Code fully after installing a packaged .vsix, since a simple reload window does not always pick up a freshly installed extension on every platform. If packaging fails, the most common cause is a missing or malformed LICENSE file or an icon path in package.json that points to a file that does not exist; vsce validates both before building the archive.
Step 12: Publish to the VS Code Marketplace and Open VSX
Publishing to the official Marketplace requires a Microsoft publisher account and a Personal Access Token from Azure DevOps. Publishing the identical package to Open VSX extends your reach to editors that cannot use the Microsoft Marketplace for licensing reasons, including Cursor, VSCodium, Gitpod, and Eclipse Theia.
# Marketplace (Microsoft)
vsce login your-publisher-name
vsce publish
# Open VSX (used by Cursor, VSCodium, Theia)
npm install -g ovsx
npx ovsx publish inline-ghost-complete-0.0.1.vsix -p <your-open-vsx-token>
Keep both tokens out of your repository and out of your shell history where possible; store them in a password manager or your CI system’s encrypted secrets store, the same discipline you are already applying to the completion model’s API key inside the extension itself.
Step 13: Measure Latency, Acceptance Rate, and API Cost
Shipping the extension is not the finish line. The real signal of whether it is useful comes from three numbers you should log locally (never to a third party without explicit consent and a clear privacy notice): how long each completion took to arrive, what fraction of shown suggestions got accepted with Tab versus dismissed, and how many tokens, and therefore how much money, each session consumed.
| Metric | What It Tells You | How to Capture It | Reasonable Target |
|---|---|---|---|
| Request-to-render latency | Whether completions feel instant or sluggish | Timestamp before fetch, timestamp when item renders | Under 300ms for a good feel |
| Acceptance rate | Whether suggestions are actually useful | Track Tab-accept vs. dismiss events on the command | Above 20% is considered healthy for autocomplete |
| Cache hit rate | How much duplicate work you’re avoiding | Counter inside CompletionCache.get() | Higher is better; depends on coding style |
| Tokens per session | Direct driver of your API bill | Sum max_tokens requested per completion call | Tune max_tokens down if this climbs unexpectedly |
| Debounce cancel rate | Whether your debounce delay is well tuned | Count cancellations vs. completed requests | A very high rate suggests shortening the delay slightly |
If you add telemetry, disclose it clearly in your extension’s README and respect VS Code’s built-in telemetry setting (vscode.env.isTelemetryEnabled) by checking it before sending anything anywhere. Several extensions have been flagged by users and reviewers for ignoring that flag, and it is a fast way to lose Marketplace trust even when the underlying feature works well.
Common Pitfalls When Building Inline Completion Extensions
A handful of mistakes show up repeatedly in extensions built on this API, and most of them are invisible in a quick demo but obvious the moment a real user with a fast typing speed tries the extension.
- Skipping debounce entirely. Calling the model on every keystroke works fine in a five-second demo and falls apart immediately in real use, racking up API costs and overwhelming slower network connections.
- Ignoring the cancellation token. Without wiring
token.onCancellationRequestedthrough to anAbortController, stale network requests keep running after they’re no longer needed, and their results can flash onto the screen out of order. - Returning completions for the wrong trigger kind. The context object includes a
triggerKind(Automatic or Invoke). Firing an expensive model call on every automatic trigger, rather than respecting the distinction, burns budget on completions nobody asked for. - Forgetting to limit the document selector. Registering the provider for all languages, including config files, Markdown, and JSON, generates nonsensical completions in places where they are actively unwanted.
- Not handling empty or whitespace-only prefixes. At the very start of a file, an empty prefix sent to the model often returns a generic, low-value completion; it’s worth special-casing a minimum prefix length before calling the API at all.
- Storing the API key in workspace settings. This is both a security mistake and a frequent cause of accidental key leaks through committed
.vscode/settings.jsonfiles. - Blocking the UI thread with synchronous work. Any heavy string processing on the full document text inside the provider callback, rather than slicing only the relevant window around the cursor, adds latency that compounds with network latency.
Troubleshooting Guide for Inline Completion Providers
These are the issues developers run into most often while building and shipping this kind of extension, roughly in the order you are likely to hit them.
- No ghost text appears at all. Confirm the document selector actually matches the open file’s language ID; a mismatch here is the single most common cause, and it fails completely silently.
- Ghost text appears, then immediately vanishes. This usually means the cancellation token fired right after your promise resolved; check that you are not accidentally calling the debounce function twice for the same keystroke.
- Extension works in the Development Host but not after packaging. Check your
.vscodeignorefile; it is easy to accidentally exclude a compiled output file that the Development Host picks up from a different, uncommitted build step. - “Activation event not found” errors. Verify
activationEventsinpackage.jsonmatches how you intend the extension to load;onStartupFinishedis usually the right choice for an always-on completion provider. - API key prompt reappears every session. This means
context.secrets.storeis not actually persisting; confirm you are awaiting the promise it returns rather than firing it and moving on. - Completions are frequently cut off mid-word. Raise
max_tokensslightly, or add a stop sequence so the model naturally ends at a line boundary instead of being truncated by the token limit. - vsce package fails with a missing repository field. Add a
repositoryfield topackage.json; recentvsceversions warn or fail outright without one. - Extension conflicts with GitHub Copilot’s own suggestions. Both extensions register inline completion providers for the same languages; VS Code merges and ranks them, but users may see unexpected interleaving. Document this clearly in your README so users understand what they are seeing.
- High latency only on large files. You are likely sending the entire file as context on every request; cap the prefix and suffix window to a few hundred lines around the cursor instead of the whole document.
- Open VSX publish rejected for missing metadata. Open VSX enforces slightly different
package.jsonrequirements than the Microsoft Marketplace, particularly around license identifiers; runovsx verify-patand check the CLI’s validation output before retrying.
Advanced Tips for Production-Grade Completion Extensions
Once the core extension works, a few refinements separate a personal side project from something developers rely on daily. First, consider multi-model routing: route short, simple completions (finishing a variable name or a closing bracket) to a fast, cheap model, and route larger, structural completions to a stronger model only when the prefix suggests genuine complexity, like the start of a new function signature. This keeps median latency low without sacrificing quality on the completions that matter most.
Second, respect context.selectedCompletionInfo when it is present. This field tells you that the user already has an IntelliSense dropdown open and has selected an item from it; a well-behaved inline completion provider should condition its suggestion on that selection rather than ignoring it and offering something contradictory.
Third, think carefully about context window construction as files grow. Sending the full prefix and suffix of a 3,000-line file on every request is wasteful and can exceed smaller models’ context limits. A better approach extracts the enclosing function or class around the cursor using a lightweight parser, plus a short summary of imports, and ignores the rest of the file. This keeps prompts small, keeps latency predictable, and tends to produce more relevant completions than a naive full-file dump.
Finally, build an explicit “quiet mode” that disables completions inside comments, string literals, and test fixtures by inspecting the token type at the cursor position through the document’s language configuration. Suggestions inside a docstring or a hardcoded test string are rarely wanted and often wrong, and suppressing them by default earns more user trust than it costs in missed suggestions.
The Complete Working Project
Putting every step together, the finished extension’s file structure looks like this:
inline-ghost-complete/
├── package.json # contributes.commands, activationEvents, configuration
├── tsconfig.json
├── .vscodeignore
├── src/
│ ├── extension.ts # activate(), provider registration, status bar
│ ├── completion.ts # fetchCompletion(), prompt construction
│ ├── cache.ts # CompletionCache class
│ ├── secrets.ts # getApiKey(), setApiKey command
│ └── test/
│ └── suite/
│ └── extension.test.ts
├── LICENSE
└── README.md
The key package.json contribution points you need, beyond what the Yeoman generator produces by default, are the two commands and the configuration schema:
{
"contributes": {
"commands": [
{ "command": "inlineGhostComplete.toggle", "title": "Toggle Ghost Completions" },
{ "command": "inlineGhostComplete.setApiKey", "title": "Set Completion API Key" }
],
"configuration": {
"title": "Inline Ghost Complete",
"properties": {
"inlineGhostComplete.debounceMs": {
"type": "number",
"default": 250,
"description": "Milliseconds to wait after typing before requesting a completion."
},
"inlineGhostComplete.maxTokens": {
"type": "number",
"default": 80,
"description": "Maximum tokens requested per completion."
}
}
}
},
"activationEvents": ["onStartupFinished"]
}
With cache.ts, completion.ts, and secrets.ts holding the logic from Steps 4, 7, and 8, and extension.ts wiring them together as shown in Steps 2, 5, 6, and 9, this is a genuinely complete, installable extension, not a fragment. Run vsce package from Step 11 against this exact structure and it will produce a working .vsix.
Why Developers Are Building Their Own Completion Extensions in 2026
The appetite for understanding and customizing AI code completion has grown alongside its adoption. According to the 2025 Stack Overflow Developer Survey, 84% of developers are using or planning to use AI tools in their development process, with 51% of professional developers reporting daily use. That scale of adoption is exactly why teams increasingly want a completion layer they control: one that can point at an internal model, respect a company’s data residency rules, or simply avoid sending proprietary code to a third-party vendor by default.
Building your own provider, even a simple one, also demystifies what tools like GitHub Copilot and Cursor are actually doing. There is no hidden magic in the ghost-text rendering itself, VS Code handles that natively once you call the registration API correctly. The real engineering, as this tutorial has shown, is almost entirely in the plumbing around it: debouncing, caching, cancellation, and prompt construction that keeps latency low enough for the suggestion to still feel relevant by the time it arrives.
For further reference while extending this project, Microsoft’s VS Code Extension API reference documents every method on InlineCompletionItemProvider and its neighbors in full, and the official vscode-extension-samples repository on GitHub includes runnable examples for related provider types. The VS Code release notes on GitHub are worth watching too, since the extension host occasionally gains small refinements to this API. If you plan to distribute outside the Microsoft Marketplace, the Open VSX registry is the destination Cursor and VSCodium both read from.
Frequently Asked Questions
What’s the difference between an inline completion provider and the Chat Participant API?
An inline completion provider renders silent ghost text directly in the editor as you type, accepted with Tab. The Chat Participant API, registered with vscode.chat.createChatParticipant, powers a conversational panel where the user explicitly types a message and receives a streamed, multi-turn reply. They solve different problems and are typically implemented as separate providers even within the same extension.
Can this extension work with a local model instead of a hosted API?
Yes. Because fetchCompletion is a plain fetch call against an OpenAI-compatible chat completions endpoint, pointing it at a local server such as Ollama or LM Studio running on localhost requires only changing the URL and removing the Authorization header if the local server doesn’t require one.
Will my extension conflict with GitHub Copilot if a user has both installed?
VS Code supports multiple inline completion providers registered for the same language and merges their suggestions, generally ranking them by provider priority and recency. Users may see suggestions from both extensions interleaved depending on context, so it’s worth documenting this behavior clearly rather than assuming your extension will be the only one active.
Does this approach work in Cursor as well as standard VS Code?
Cursor is a fork of VS Code and supports the same extension API surface, including InlineCompletionItemProvider, for extensions installed through Open VSX. Since Cursor ships its own built-in completion engine, test carefully for any UI overlap or duplicate suggestions when both are active in the same session.
How much does running a completion extension like this typically cost per developer?
Cost depends entirely on your model choice, your max_tokens ceiling, your debounce delay, and your cache hit rate, all of which this tutorial gives you direct control over. There is no fixed industry figure to quote here; the right move is to log tokens-per-session yourself, as shown in Step 13, and tune the debounce and caching parameters against your own usage data rather than assuming a number from a different team’s setup.
Do I need a paid Microsoft publisher account to publish to the Marketplace?
No. Creating a Microsoft publisher identity and generating a Personal Access Token through Azure DevOps is free. There is no listing fee to publish an extension to the Visual Studio Code Marketplace, and the same applies to Open VSX.
What VS Code version should I target for compatibility?
Set engines.vscode in package.json conservatively, something like ^1.85.0, rather than pinning to the current 1.140 release. The Inline Completion API has been stable for several years, and targeting an older minimum version keeps your extension usable by developers who haven’t updated yet without losing any functionality this tutorial relies on.
Why does my completion sometimes repeat text that’s already on the line?
This happens when the model doesn’t clearly understand where the prefix ends, usually because the suffix wasn’t included in the prompt or the system instruction wasn’t explicit enough about returning only new text. Revisit the prompt structure in Step 4 and confirm both prefix and suffix are being sent on every request, not just the prefix.





