Separating a VS Code Extension from a TypeScript Core: Architecture Lessons from Aqiron Security A developer behind Aqiron Security, an open-source application security project, separated the product's security and runtime logic from its VS Code extension into a standalone Node.js and TypeScript core process. The extension now acts as a client, launching the core locally and communicating over newline-delimited JSON via stdin/stdout, so the core no longer depends on the VS Code API. The design keeps packages/core private and bundled into the extension, with the boundary intended to allow future packaging changes. When I started building Aqiron Security, the natural approach was to keep the security logic inside the VS Code extension. That works. At least initially. But as the project started accumulating scanner orchestration, finding normalization, correlation, project intelligence, RAG, reporting, and AI operations, I started running into a deeper architectural question: Should the VS Code extension actually own the application's core runtime? I decided the answer should be no. Aqiron Security is an open-source application security project. The current product is a VS Code extension, but I wanted the security/runtime layer to have a much cleaner boundary from the client. The result is a design where the extension acts as the client and starts a separate Node.js process containing the TypeScript core. This article explains why I made that separation, how the current architecture works, and some of the trade-offs I have encountered. The original problem A VS Code extension has a lot of responsibilities already. It deals with things like: extension activation commands diagnostics the VS Code API webview communication editor state user settings UI lifecycle Security scanning introduces another class of responsibilities: starting external tools parsing scanner output normalizing findings correlating duplicate results generating reports managing long-running operations cancellation AI operations workspace analysis Putting all of that into one runtime makes the boundary blurry. The code may still work, but over time the architecture starts answering questions like: "Does this security service need VS Code?" That is a dangerous dependency to create if the answer does not actually need to be yes. The current Aqiron architecture looks roughly like this: ┌──────────────────────────────────────────────┐ │ VS Code Extension │ │ │ │ activation / commands / diagnostics │ │ React webview / settings / workspace UI │ │ ScanController / client-side services │ └──────────────────────┬───────────────────────┘ │ │ CoreClient │ CoreProcessManager │ │ newline-delimited JSON ▼ ┌──────────────────────────────────────────────┐ │ Aqiron Core Runtime │ │ Node.js + TypeScript │ │ │ │ protocol / cancellation / adapters │ │ scanning / analysis / correlation │ │ reports / RAG / AI operations │ └──────────────────────┬───────────────────────┘ │ ┌────────────┴────────────┐ ▼ ▼ Native Aqiron rules External scanners Trivy / Semgrep OSV-Scanner / Betterleaks / MobSF │ │ └────────────┬────────────┘ ▼ Unified findings ▼ correlation + graph ▼ reports This is not a cloud architecture. The current project is local-first. The extension starts the core process locally and communicates with it over standard input/output. The repository currently keeps packages/core private and bundles it into the extension rather than publishing it as a separate npm package. That distinction is important. The boundary exists today, but the eventual product packaging can evolve later. There were several reasons. The first reason is architectural independence. The core should not need to know that VS Code exists. Ideally, security logic should be able to operate on concepts like: workspace scan finding project report AI request rather than: vscode.workspace vscode.window WebviewPanel TextDocument DiagnosticCollection The extension is responsible for translating between the developer environment and the core. That gives me a much cleaner dependency direction: VS Code client ↓ Core VS Code ↔ Security logic ↔ VS Code Using a separate process also creates a runtime boundary. The extension host and the security core no longer execute as one giant logical process. That matters for long-running operations. A scan might involve: discover files ↓ run multiple tools ↓ parse results ↓ normalize findings ↓ correlate findings ↓ build relationships ↓ generate reports That's a very different workload from handling an editor command or updating a sidebar. With a separate core process, the extension can treat the security engine more like a service. That makes lifecycle handling, restart behavior, and failure boundaries easier to reason about. This was probably the most valuable part of the architecture. Once the extension and core became separate processes, they couldn't casually call each other's internal functions anymore. They needed a protocol. The current protocol is intentionally simple: stdin/stdout + newline-delimited JSON A request looks conceptually like: interface CoreRequestMessage { id: string; type: "request"; method: string; params?: unknown; } And a response: interface CoreResponseMessage { id: string; type: "response"; success: boolean; result?: unknown; error?: CoreProtocolError; } There are also event messages for asynchronous pipeline updates: interface CoreEventMessage { type: "event"; event: string; requestId?: string; payload?: unknown; } The actual protocol also has a versioned handshake. For example, the current runtime exposes a protocol version and core version and can report compatibility states such as: compatible protocol-mismatch extension-too-old core-too-old unsupported That gives us an explicit compatibility boundary instead of relying on both sides silently assuming they agree. I deliberately didn't start with something complicated. The current transport is essentially: message 1\n message 2\n message 3\n where each line contains one JSON message. That gives us a few useful properties: You can literally look at the communication stream. Malformed input can be identified and rejected. The extension starts the process locally and communicates through stdio. The wire format is JSON rather than TypeScript-specific objects. That last point matters. The implementation is TypeScript, but the protocol doesn't fundamentally need to be. Once requests and responses cross a process boundary, we need a way to know which response belongs to which request. That's why requests carry an ID. For example: request id: abc123 method: scan.start The response can return: id: abc123 success: true Without this, concurrent operations become painful to reason about. The ID becomes the connection between: request ↓ core operation ↓ response and also gives us something useful for cancellation and pipeline events. Security operations shouldn't be treated as unstoppable functions. Imagine starting a deep scan and then closing the workspace. Or starting an AI analysis and then deciding you don't need it anymore. The architecture therefore includes explicit cancellation operations. The current core protocol exposes operations such as: core.cancel scan.cancel ai.cancel and the runtime propagates cancellation through the relevant cancellation sources and scanner context. This is one of those details that seems unnecessary until you have a real long-running operation. Then it becomes essential. One of the goals of the boundary is to let the client ask for a scan without knowing the implementation details. Conceptually: await coreClient.startScan { workspaceRoot, mode: "deep", trusted: true } ; The extension doesn't need to know: which scanners are installed how scanner output is parsed how findings are normalized how correlation works how reports are generated Those concerns belong to the core pipeline. The current core pipeline can combine native Aqiron rules with optional external scanners, normalize results into UnifiedFinding , then pass them through correlation/graph processing and report generation. That separation is the main reason I like this architecture. A scanner may produce one format. Another scanner produces something completely different. Scanner A severity = HIGH file = foo.dart line = 41 while another might report: Scanner B level = error path = foo.dart startLine = 41 The core shouldn't force the rest of the system to understand every scanner's native format. Instead, scanner-specific parsers convert the output into a common model. external scanner ↓ scanner-specific parser ↓ UnifiedFinding ↓ correlation ↓ report This is one of the biggest advantages of having an application-level core rather than scattering scanner logic throughout the VS Code extension. This is important because architecture diagrams can easily make an early project look more mature than it actually is. Aqiron is currently version 0.0.1 and under active development. There are still intentional limitations. workspace operations currently require a Flutter workspace external scanners are optional the core is still bundled into the extension quick file scans use a separate direct extension path the project does not yet have independently published Core, CLI, or Desktop packages So the current architecture is not: Aqiron Core npm package ↓ VS Code CLI Desktop Not yet. It's closer to: VS Code ↓ internal Core process ↓ bundled runtime That is an important distinction. The architecture is being prepared for broader reuse without prematurely creating a bunch of packages that don't yet need to exist. Why I didn't immediately split everything into repositories This was another deliberate decision. It would be easy to say: aqiron-core aqiron-security-vscode aqiron-security-cli aqiron-security-desktop and create four repositories immediately. But that would add operational complexity before the products existed. You would now have to manage: package publishing version coordination cross-repository changes release synchronization dependency management contributor workflow across multiple repositories The current repository gives me a cleaner intermediate step: src/ packages/core/ with an explicit runtime boundary. When multiple clients become real products, the repository structure can change. Until then, the architecture can evolve without forcing the project to pay the cost of premature distribution. The biggest win isn't actually "using IPC." The bigger win is making the boundary explicit. The VS Code extension owns the developer environment. The core owns security operations. The protocol connects them. That gives us a mental model like: Client responsibilities ↓ UI VS Code commands diagnostics workspace interaction │ │ protocol ▼ Core responsibilities ↓ scanning normalization correlation RAG AI operations reports That's much easier to reason about than a single giant extension runtime. The architecture also introduces new problems. A process boundary is not free. Now we have to care about: process startup time restart behavior malformed messages protocol compatibility stderr/stdout handling partial failures cancellation shutdown concurrent requests serialization overhead In other words: We traded code coupling for process-boundary complexity. I think that's a reasonable trade for Aqiron, but it isn't automatically the right choice for every VS Code extension. If your project is a small command-based extension with a few hundred lines of logic, this architecture would probably be overkill. For a growing security platform with multiple subsystems and long-running operations, the boundary becomes much more interesting. The long-term idea is not "make a complicated VS Code extension." It's to make the security core reusable. The future might eventually look like: Aqiron Core / | \ / | \ ↓ ↓ ↓ VS Code CLI Desktop But I don't need to build all three clients today. Right now, I'm using the VS Code extension as the first real client and using the Core boundary to keep the architecture ready for future evolution. That's the part I'm most interested in getting right. The biggest lesson I've taken from this project is that architecture isn't about drawing the biggest possible diagram. It's about deciding where responsibilities should stop. For Aqiron, the important boundary became: VS Code is the client. The TypeScript runtime is the security engine. IPC is the contract between them. That doesn't mean the architecture is finished. It means there is now a clear place to evolve it. I'm still working through the trade-offs, so I'd be interested in hearing from people who have built: VS Code extensions with external processes TypeScript/Node developer tools language-server-style architectures security scanners CLI + GUI products sharing a common runtime Aqiron Security is open source and currently under active development.