Building a VS Code Extension That Treats the Install as the Conversion Event
TLDR
A VS Code extension that requires sign-in to use its core features has misunderstood its own conversion funnel. The install is the conversion event. Everything after that should work. Auth in an extension exists to surface tier and personalization, not to enforce limits.
The Problem Class
Any developer who ships both a web product and a VS Code extension faces this decision: do you apply the same usage limits to both surfaces? The instinct is yes -- consistency, one monetization model, less code to maintain.
The instinct is wrong. The two surfaces have fundamentally different conversion economics.
On the web, a visitor costs nothing. You have thousands of anonymous sessions per day and a small fraction convert to accounts. The gate is a conversion mechanism -- it turns engaged anonymous users into known users at the moment of maximum intent.
In a VS Code extension, the user already installed the extension. That action required opening the Marketplace, finding the extension, clicking install, and restarting VS Code. The install is not a casual visit. It is a deliberate act of intent that took more effort than any web signup flow. The user has already converted. Putting a usage gate after the install is making them convert twice.
The Wrong Model
The web gate pattern applied to an extension looks like this:
// Extension host: ginexys:analyze-check handler
if (!token) {
const count = parseInt(await secrets.get('guest_count') ?? '0', 10);
if (count < GUEST_LIMIT) {
await secrets.store('guest_count', String(count + 1));
allowed = true;
} else {
allowed = false; // blocked after 2 uses
}
}
This pattern makes sense on the web. A session counter in localStorage gates anonymous usage and nudges sign-in. In an extension, it means the user who installed your tool gets blocked after two uses by a counter stored in their own extension secrets. They did not bounce. They committed. You just locked them out of what they installed.
Why It Breaks
The web gate works because "anonymous session" and "intent to use" are weakly correlated. Many visitors are just exploring. The gate separates explorers from committed users.
In an extension, the correlation is near-perfect. Nobody installs an extension they are not going to use. "Anonymous extension user" is almost never an explorer. It is a committed user who has not yet signed in.
A guest counter that triggers after 2 uses will hit every serious user within the first session. The extension becomes hostile to the exact people who are most likely to become paying customers.
The Better Model
Auth in an extension serves one purpose: surface tier. If the user is signed in and has a Pro subscription, show Pro features. If they are not signed in, show Free features. Never block core functionality.
// Extension host: ginexys:analyze-check handler
let token: string | null = null;
try { token = await authProvider.getToken(); } catch { token = null; }
// Always allow. Tier determines which features are visible, not whether core works. let allowed = true; let tier = 'free';
if (token) { try { const res = await fetch('https://your-api.com/api/me', { headers: { Authorization: Bearer ${token} }, }); if (res.ok) { const data = await res.json(); tier = data?.tier ?? 'free'; } } catch { / network error -- tier stays 'free', allowed stays true / } }
panel.webview.postMessage({ type: 'analyze-response', payload: { allowed, tier }, });
The response is always allowed: true. Tier is resolved non-blocking -- if the API call fails or the user is not signed in, they get free. No counter, no block, no prompt.
Pro features in the webview check tier === 'pro' before rendering. Free users see a "Pro feature" indicator, not a wall. They can sign in to unlock, but the tool works without it.
The Injection Problem
There is a second layer to this: in an extension webview, the tool itself may have been built for the web and expects certain runtime behaviors that only exist in the browser context. A sign-in modal that works via window.GxAuth.open() on the web will not work in a webview because it relies on browser session storage, OAuth redirects, and BroadcastChannel -- none of which function correctly in a sandboxed webview.
The correct approach: move the auth gate entirely out of the webview and into the extension host. The webview asks the host "am I allowed?", the host answers with tier information, and the webview never tries to run its own auth flow. The extension host handles sign-in via the system browser and OS keychain.
// Webview sends:
vscode.postMessage({ type: 'analyze-check', __ginexys: true });
// Extension host receives and replies: panel.webview.postMessage({ type: 'analyze-response', payload: { allowed: true, tier: resolvedTier }, });
The webview does not know how auth works. The host does. This separation is also correct from a security perspective -- the webview is sandboxed and should not handle tokens.
Tradeoffs
An ungated extension means your free-tier usage numbers are higher and your conversion rate from extension install to paid tier will look lower than it actually is. Attribution is harder when the extension always works.
The tradeoff is worth it. Extension users who are blocked will uninstall. Extension users who always get value will stay, and a percentage will sign in to unlock Pro features when they encounter them naturally. That conversion is higher quality than a conversion driven by a hard block.
The One Thing to Watch For
If your extension injects a closed-source script into the webview (a pattern common when a tool has a web build that is partially open-source), inject it after the webview signals ready, not immediately after setting panel.webview.html. The webview HTML is parsed asynchronously. A postMessage sent before JavaScript has run is lost silently.
// Wrong -- message sent before JS is running
panel.webview.html = html;
panel.webview.postMessage({ type: 'inject-script', src: scriptUri });
// Correct -- wait for the ready signal panel.webview.onDidReceiveMessage(msg => { if (msg.type === 'tool-ready') { panel.webview.postMessage({ type: 'inject-script', src: scriptUri }); } });