So, I have been working toward building an application called envpilot.dev for a very long time. I have VS Code extension support, Cursor support, GitHub Actions, Docker, a CLI, and a lot more, along with a web application. The codebase is getting much bigger and more complex, and yet I’ve somehow managed to make everything work in a mostly monolithic way, which is honestly pretty fascinating.
But here’s the thing: I never added support for Android Studio or any of the IntelliJ products because, first of all, I don’t use an IDE, and second, I had absolutely no idea how any of it worked.
I’ve been in the TypeScript and JavaScript ecosystem for so long that I genuinely had no idea how to develop an IntelliJ plugin from scratch.
So, this is the rabbit hole we’re going down: how do we build a full-on IntelliJ plugin from scratch?
And by “from scratch,” I mean dealing with the nightmare of setting up Gradle, downloading different versions, figuring out compatibility issues between older and newer versions of IntelliJ, dealing with preview versions, understanding the uploading and publishing process, getting through approval and security scanning, making sure I’m not using deprecated APIs, and, of course, all the other fun stuff that comes with building an IntelliJ plugin.
Basically, welcome to the rabbit hole.
First of all, historically, IntelliJ plugins and, by extension, plugins for Android Studio were primarily built in Java. And I haven’t touched Java since I was in college.
Then Google officially announced that Kotlin would become the preferred language for building Android applications. Kotlin is a modern, strongly typed language with a lot of functional programming features, and honestly, I love that style of programming because it makes my life much easier when I’m writing code.
But coming from the TypeScript and JavaScript ecosystem, getting back into the JVM world was a bit of a reality check. I’ve spent years working with TypeScript, where functional patterns are everywhere, and suddenly I had to deal with all those object-oriented concepts, classes, interfaces, inheritance, and the general structure of a JVM-based project again.
It was a strangely nostalgic and memorable moment.
I actually had to go back to some of my old college lectures and notes just to remind myself how things were supposed to be structured. How should I architect this application? Where does each piece of logic belong? How does the plugin lifecycle work? And, most importantly, how do I make all of this actually work together?
That was essentially the first hurdle.
The very first step is getting the Kotlin project initialized correctly. Thankfully, there’s a small utility provided by the IntelliJ ecosystem that can help you bootstrap a plugin project and get the initial project structure in place.
And that’s where the journey really begins. Taking a completely unfamiliar ecosystem, getting the project initialized, understanding what all these generated files actually do, and slowly figuring out how an IntelliJ plugin is supposed to be built.
So, as you can see in the image above, this was the structure I got when I initially initialized the project.
One important detail here is that I did not use Android Studio or IntelliJ to create this project. Honestly, I was probably being a little stupid. I did not even think about using the IDE to do the initial setup.
Instead, I opened VS Code, installed the Kotlin and Android extensions, and started putting the project structure together manually. I used the CLI to figure out what needed to be installed and how everything needed to be configured. I honestly do not even remember which exact commands I used at the time to install Gradle and set everything up.
A lot of this process was basically me asking the internet increasingly specific questions.
How should I initialize an IntelliJ plugin project? How do I configure the SDK? How do I make the terminal recognize the SDK and the project directory I have open? Which Gradle version should I use? Which IntelliJ platform version should I target?
I had Android Studio installed on my machine, but I had barely ever used it. So opening this kind of project and trying to understand what was going on was actually a really fun experience.
Once you look at the project structure, though, it starts to feel surprisingly familiar if you have worked with Android projects before. You have the build directory, the Gradle directory, the source directory, and then your main Kotlin source set. Inside that, you have your packages and application code.
I started organizing the project into several different areas as the codebase grew. Things like actions, authentication, configuration, Convex, additions, errors, guards, UI, sync, and so on.
This was actually a significant change from the very first version of the plugin.
The first version I wrote was basically me dumping everything into a few massive files. I did not really understand how an IntelliJ plugin should be structured, so I was just looking things up, integrating APIs one by one, and trying to make the application work.
And, somehow, it did.
But it was not good.
Once I started understanding the ecosystem better, I realized that I needed to rethink the architecture instead of continuing to pile more code onto what I already had.
This is where Convex became particularly interesting.
My main backend service for Envpilot is Convex. I really like Convex because it gives me the backend primitives I need without forcing me to build and maintain a traditional REST API layer for every client. The web application, CLI, extensions, and other parts of Envpilot already communicate with the same backend.
So I started looking into how Convex handled Android and Kotlin.
And then I found out that Convex already had support for Android applications.
That immediately made me think: if they already support Kotlin and Java based applications, there is a very good chance that I can use the same underlying infrastructure for an IntelliJ plugin.
That was the moment when I decided to stop trying to build a completely separate backend integration for the plugin.
Instead, I rewrote the entire integration around the same backend primitives that the rest of Envpilot uses.
That meant rewriting the authentication layer, the API layer, the WebSocket communication, the real-time synchronization, and a bunch of the supporting infrastructure around them.
The code I’m going to show you is open source as well. I’ll link the repository and the pull request where I worked on this implementation so you can go through the actual codebase yourself.
And before we go any further, a small disclaimer.
The code is not perfect.
I tried my best to understand Kotlin and write what I considered to be reasonably elegant code, but I had not used Kotlin or Java seriously in a very long time. So if you are a Kotlin expert reading this and wondering why I made a particular decision, please forgive me.
And if something is especially questionable, I’m going to blame AI.
With that out of the way, let’s look at the three pieces that make this integration work: the Convex API layer, the Convex WebSocket implementation, and the synchronization service.
The Convex API Layer
The first thing I wanted was a small abstraction around the backend.
The rest of Envpilot already has a set of backend operations for organizations, projects, variables, files, authentication metadata, and device linking. I did not want the IntelliJ plugin to invent an entirely new API surface just for itself.
Instead, ConvexApi acts as the data-plane client for the plugin.
For example, fetching organizations is essentially a call to the same Convex query that other Envpilot clients use:
suspend fun orgs(): List<Org> {
val body = socket().query(
"features/organizations/queries:listForUser",
emptyMap()
)
return parseArray(body).map { o ->
Org(
o.str("_id") ?: "",
o.str("name") ?: "",
o.str("slug") ?: "",
o.str("role")
)
}
}
The interesting part here is that the plugin is not talking to a separate REST endpoint. It is communicating directly with the Convex backend over the same socket infrastructure.
The API layer then converts the raw JSON responses into Kotlin data models such as Org, Project, PullMeta, PullResult, and SecretFileMeta.
For example, pulling variables from a project is handled through a Convex action:
suspend fun pullValues(
projectId: String,
environment: String?,
metadataOnly: Boolean,
): PullResult {
val body = socket().action(
"features/variables/values:pullValues",
buildMap {
put("projectId", projectId)
environment?.let { put("environment", it) }
put("metadataOnly", metadataOnly)
},
)
// Parse the response and convert it into Kotlin models...
}
The same abstraction also handles secret files, creating variable requests, linking a device, and unlinking a device.
The important architectural decision here is that the plugin is not trying to recreate Envpilot's backend logic. It is simply another client of the existing backend.
That is something I really wanted to preserve.
The WebSocket Layer
The more interesting part is the WebSocket implementation.
The IntelliJ plugin needs real-time synchronization. If a variable changes somewhere else, I want the IDE to know about it without requiring the user to manually refresh everything.
So I implemented a ConvexSocket that manages the entire WebSocket lifecycle.
The lifecycle is essentially:
Connect → Authenticate → Subscribe → Receive updates → Reconnect when necessary
The socket keeps track of things like the current connection, connection count, identity version, query set version, query IDs, active subscriptions, pending actions, pending queries, and the last time a message was received from the server.
One of the things I particularly wanted was for subscriptions to survive reconnects.
When the socket connects, it restores the query set and resubscribes to everything that was previously active. That means the rest of the plugin does not need to constantly worry about whether the underlying connection has disappeared and come back.
The subscribe method is intentionally small. It creates a query ID, stores the subscription, stores its arguments, and sends the query change over the socket.
For one-shot operations, I use the same socket differently.
Actions and mutations are sent through the socket and then paired with a CompletableDeferred. The request waits for the corresponding response, with a 30-second timeout so that the caller does not hang forever if something goes wrong.
Queries work similarly, except they temporarily create a subscription, wait for the first result, and then unsubscribe.
That gives me a fairly clean API from the rest of the plugin:
val projects = ConvexApi.projects(organizationId)
val variables = ConvexApi.pullValues(projectId, environment, false)
The complicated WebSocket mechanics stay underneath the abstraction.
Speaking Convex's Wire Protocol
The final piece is ConvexWire.
This is where things get particularly interesting because I am not simply using some generic WebSocket API and hoping everything works.
The plugin needs to understand the wire format that Convex uses for its synchronization protocol.
So I created a small Kotlin representation of the messages that matter to the plugin.
There are server messages for things like Ping, Transition, AuthError, and FatalError. There are also messages for adding and removing query subscriptions, authenticating, executing actions, and executing mutations.
For example, the connection message contains the session ID, connection count, and client timestamp:
fun connectMessage(
sessionId: String,
connectionCount: Int,
): String {
val obj = JsonObject().apply {
addProperty("type", "Connect")
addProperty("sessionId", sessionId)
addProperty("connectionCount", connectionCount)
add("lastCloseReason", JsonNull.INSTANCE)
addProperty("clientTs", System.currentTimeMillis())
}
return gson.toJson(obj)
}
The query subscription protocol is handled through ModifyQuerySet, which lets the client add and remove subscriptions while maintaining a query set version.
That matters because the plugin can have multiple projects being watched at the same time.
The WebSocket layer also has to deal with authentication. When a connection is established, the plugin sends the Connect message first and then authenticates before restoring the subscriptions. The implementation explicitly does this because queries sent before authentication can otherwise come back as unauthenticated failures on some deployments.
And then there is the part that I really did not want to get wrong: reconnection.
Connections fail. Networks disappear. Laptops sleep. VPNs change. Servers restart.
So the socket implements reconnect logic with exponential backoff, capped at 30 seconds. It also has a watchdog that considers the connection unhealthy if the server has been silent for more than 60 seconds and forces a reconnect.
When a socket disconnects, I also make sure that any callers currently waiting for an action or query do not remain suspended forever. Pending actions are completed with an error, and pending queries are completed exceptionally.
Finally, when a Transition message arrives, the wire layer determines which queries were updated and passes those events back up to the synchronization service. That is what ultimately tells Envpilot that something changed and that a sync cycle needs to run.
Putting Everything Together
The interesting part is that these three pieces have very different responsibilities.
ConvexWire understands the protocol.
ConvexSocket manages the connection and turns that protocol into something the rest of the application can use.
ConvexApi exposes actual Envpilot operations such as fetching projects, pulling variables, reading secret files, and linking devices.
And above all of that sits the synchronization layer, which decides what those real-time updates actually mean for an IntelliJ project.
This separation is something I absolutely did not have in the first version.
The first version was essentially one giant pile of code that happened to work.
This version is much closer to how I would normally structure a TypeScript application. There are clear boundaries, responsibilities are separated, and the transport layer does not need to know what a project sync actually means.
And that brings me to the next rabbit hole: Kotlin itself.
Because after spending years writing TypeScript, coming back to a JVM language and actually having to think in Kotlin was probably the most interesting part of this entire experiment.
I had to relearn a lot of things, discover how Kotlin approaches problems that I would normally solve differently in TypeScript, and figure out where my existing programming habits translated well and where they absolutely did not.
And that is what I want to dive into next.
Getting the Plugin to Actually Feel Native
Once the backend, authentication, synchronization, and everything else were properly configured, the next thing I focused on was the actual experience inside the IDE.
The UI and UX were already working surprisingly well. Project linking was working, pulling files from the development server was working, pulling metadata and variables was working, and all the major pieces were finally coming together.
At that point, I was pretty happy with the first real state of the application.
Sure, there were still some missing icons and a few visual details that needed work, but those were relatively easy to solve. I already had the SVGs from the other Envpilot clients, so I just had to redefine them as components that could be used inside the Kotlin application. After making a few small adjustments to sizing and spacing, everything started rendering properly.
And honestly, it went much better than I expected.
The plugin started blending into the IntelliJ interface instead of looking like something that had been bolted onto the side of the IDE.
That was very important to me.
I did not want the IntelliJ plugin to feel like a completely different product. My goal has always been to have one-to-one feature parity between the VS Code extension, the Cursor extension, and now the IntelliJ plugin, while still making each integration feel native to its respective environment.
There are obviously some differences between the platforms, but the core experience should remain the same.
Kotlin Was Surprisingly Nice
One of the things that surprised me during this process was how much I started enjoying Kotlin.
Coming from TypeScript, I initially expected Kotlin to feel much more foreign than it actually did. There are a lot of concepts that felt familiar, especially when working with functions, collections, nullable values, and the general ability to express fairly complex operations without writing a huge amount of code.
The IntelliJ ecosystem also makes it surprisingly easy to work with the platform APIs.
I can write a relatively small amount of code, import the APIs I need, and then build fairly sophisticated editor behavior on top of them.
For example, one of the features I wanted to bring over from the VS Code and Cursor extensions was something I call Env Cloak.
The idea is simple.
When you open an .env file inside the editor, Envpilot can detect which values are managed secrets and hide those values directly in the editor. Instead of displaying the actual secret, the editor renders a placeholder.
This is not just a cosmetic change where the text is recolored or made transparent. The plugin actually creates folding regions at the editor level, so the secret value is not rendered normally at all.
For .env files, managed values are replaced with a placeholder such as:
••••••••
For other managed secret files, such as JSON, PEM, or keystore files, the plugin can fold the entire document and display a locked placeholder instead.
The implementation is fairly straightforward once you understand the IntelliJ editor APIs:
val region = editor.foldingModel.addFoldRegion(
range.first,
range.last + 1,
ENV_PLACEHOLDER
)
region?.isExpanded = false
The plugin identifies the values that belong to the linked Envpilot project and then creates fold regions around those values.
There is also a document listener attached to the editor, so when the document changes, the cloak can be refreshed automatically.
This is one of those features that sounds complicated when you describe it, but once you understand the platform API, the implementation becomes surprisingly clean.
And this is something I really like about Kotlin and the IntelliJ platform.
The APIs are expressive enough that I can describe what I want the editor to do without having to build an enormous abstraction layer around it.
There Is Still Work to Do
Of course, the IntelliJ plugin is not at complete feature parity with the VS Code and Cursor extensions yet.
There are still a few things missing.
For example, the webview-based application experience is not currently available in the Kotlin plugin. The inline autocomplete experience for importing environment variables is also not implemented yet.
Those are things I plan to work on in the coming iterations.
But the core functionality is already there.
Authentication works. Project linking works. Variable synchronization works. File pulling works. Secret protection works. The error handling infrastructure works. And the platform gives me a lot of the pieces I need to continue building the remaining features.
That brings me to another thing I really appreciate about the IntelliJ ecosystem: the platform itself takes a lot of responsibility for things that I would otherwise have to build manually.
Error Handling and Developer Experience
Some of the error handling and global error tracking that I already use throughout Envpilot translated surprisingly well into the plugin.
The platform has solid support for handling errors, tracking application state, managing services, and integrating those things into the IDE lifecycle.
I had honestly underestimated how useful that would be.
When you are building a JavaScript or TypeScript application, especially one with a large and increasingly complicated codebase, maintaining consistent error handling and application-wide tracking can become a pain.
There are a lot of different execution paths, environments, integrations, and edge cases.
The IntelliJ platform provides a lot of structure around these things.
That means I can focus more on the actual Envpilot functionality instead of constantly worrying about how every single piece of infrastructure needs to be wired together.
And Then Came Compatibility
The next challenge was something I had not really considered when I started this project.
IntelliJ is not just one version.
There are different IntelliJ platform versions, different IDE versions, preview releases, and changes to the APIs over time. So even after getting the plugin working locally, I still had to make sure that it actually worked across the versions I wanted to support.
This is where the JetBrains plugin marketplace tooling became really interesting.
As I started preparing the plugin for publishing, I could run compatibility verification against different IntelliJ IDEA versions.
And this is where the screenshot above comes in.
You can see several versions of the plugin listed, with compatibility verification results for different IntelliJ IDEA versions. The current version was successfully verified against multiple IDE versions, and the results were all green.
That was not always the case.
During the earlier versions of the plugin, I ran into compatibility issues and deprecated APIs. I had to go through the codebase, figure out which APIs were no longer recommended, replace them, and then run the verification again.
Eventually, I got to the point where the compatibility checks were passing cleanly.
And I was genuinely impressed by this.
The JetBrains ecosystem does a really good job of telling you which APIs are deprecated, which APIs are compatible with specific platform versions, and whether your plugin is actually behaving correctly when it runs inside a particular IDE version.
The compatibility verification process essentially gives you another layer of confidence before you publish the plugin.
The current plugin version was going through verification against multiple IntelliJ versions, and seeing those green "Compatible" results was one of those small moments that made the whole process feel real.
This was no longer just a plugin running on my machine.
I was actually preparing it for the IntelliJ Marketplace.
And that introduced an entirely new set of problems to solve.
I now had to think about plugin signing, marketplace publishing, compatibility verification, security scanning, deprecated APIs, version ranges, review, and everything else that comes with distributing a plugin to other developers.
That entire publishing process deserves its own section, because this is where building an IntelliJ plugin went from being a fun experiment into something that actually felt like shipping a real product.
That is it from my side today. If you are really interested into the application that I'm building, then check out envpilot.dev and if you want to reach out then email me at 99marafay@gmail.com
Until them I am fixing more bugs...


Top comments (1)
I forget to link the repo: github.com/rafay99-epic/envpilot.dev
The app: envpilot.dev
the plugin: plugins.jetbrains.com/plugin/33946...