IntelliJKotlinTutorial··10 min read

Building an IntelliJ Plugin in Kotlin: A Status Bar Widget That Talks to an API

From build.gradle.kts to a live widget in IntelliJ IDEA, WebStorm, and all JetBrains IDEs — the complete walkthrough, including the parts the docs don’t tell you.

The context

I built Cachly, an AI memory layer. AI assistants call into it via MCP to store and recall lessons across sessions. After building the VS Code extension, the natural next step was IntelliJ — half our users live in JetBrains IDEs.

MCP 0.5.4 note: Sessions are now fully automatic. The brain starts tracking on the first tool call and saves a summary on IDE close. No manual session_start / session_end needed.

Step 1: Gradle setup with IntelliJ Platform Plugin 2.x

Forget the old devkit approach. In 2024+, the IntelliJ Platform Gradle Plugin 2.x is the way to go:

// build.gradle.kts
plugins {
    id("java")
    id("org.jetbrains.kotlin.jvm")         version "1.9.25"
    id("org.jetbrains.intellij.platform")  version "2.2.1"
}

group   = "dev.cachly"
version = "0.2.0"

repositories {
    mavenCentral()
    intellijPlatform { defaultRepositories() }
}

dependencies {
    intellijPlatform {
        intellijIdeaCommunity("2024.1")
        instrumentationTools()
    }
    implementation("com.google.code.gson:gson:2.11.0")
}

What I learned

The intellijPlatform block inside repositories is new in Plugin 2.x. Without defaultRepositories(), Gradle can’t resolve the IntelliJ SDK and the error messages are completely unhelpful. Use Gson over Jackson — IntelliJ bundles its own Jackson and version conflicts are painful.

Step 2: The plugin descriptor

<!-- src/main/resources/META-INF/plugin.xml -->
<idea-plugin>
    <id>dev.cachly.brain</id>
    <name>Cachly Brain</name>
    <vendor email="[email protected]" url="https://cachly.dev">Cachly</vendor>

    <!-- platform = works in ALL JetBrains IDEs, not just IntelliJ IDEA -->
    <depends>com.intellij.modules.platform</depends>

    <extensions defaultExtensionNs="com.intellij">
        <statusBarWidgetFactory
            id="CachlyBrainWidget"
            implementation="dev.cachly.brain.CachlyStatusBarWidgetFactory"
            order="after positionWidget" />

        <applicationConfigurable
            id="dev.cachly.brain.settings"
            displayName="Cachly Brain"
            instance="dev.cachly.brain.CachlySettingsConfigurable" />

        <applicationService
            serviceImplementation="dev.cachly.brain.CachlySettings" />
    </extensions>
</idea-plugin>

depends com.intellij.modules.platform makes the plugin compatible with PyCharm, WebStorm, GoLand, and all other JetBrains IDEs automatically. The order="after positionWidget" places the widget after the line:column indicator — without explicit ordering it may end up hidden.

Step 3: Persistent settings

@Service
@State(name = "CachlySettings", storages = [Storage("CachlyBrainPlugin.xml")])
class CachlySettings : PersistentStateComponent<CachlySettings.State> {

    data class State(
        var apiKey: String = "",
        var instanceId: String = "",
        var apiUrl: String = "https://api.cachly.dev",
        var refreshIntervalSec: Int = 300,
    )

    private var myState = State()

    override fun getState(): State = myState
    override fun loadState(state: State) { myState = state }

    companion object {
        fun getInstance(): CachlySettings =
            ApplicationManager.getApplication()
                .getService(CachlySettings::class.java)
    }
}

IntelliJ serializes this class to an XML file in ~/.config/JetBrains/... automatically — no custom file I/O needed. Pair it with a Configurable implementation and users get a polished settings page under Settings → Tools → Cachly Brain.

Step 4: The status bar widget

IntelliJ’s status bar API requires two classes: a Factory and the Widget itself.

class CachlyStatusBarWidgetFactory : StatusBarWidgetFactory {
    override fun getId()            = "CachlyBrainWidget"
    override fun getDisplayName()   = "Cachly Brain"
    override fun isAvailable(project: Project) = true
    override fun createWidget(project: Project) = CachlyStatusBarWidget(project)
}

class CachlyStatusBarWidget(private val project: Project) :
    StatusBarWidget, StatusBarWidget.TextPresentation {

    private var statusBar: StatusBar? = null
    private var currentText = "🧠 Brain: ..."
    private val scheduler = Executors.newSingleThreadScheduledExecutor()

    override fun ID()           = "CachlyBrainWidget"
    override fun getPresentation() = this
    override fun getText()      = currentText
    override fun getTooltipText() = "Cachly Brain Health — click for details"

    override fun getClickConsumer() = Consumer<MouseEvent> {
        ShowBrainHealthAction().showPanel(project)
    }

    override fun install(statusBar: StatusBar) {
        this.statusBar = statusBar
        val interval = CachlySettings.getInstance().state.refreshIntervalSec.toLong()
        scheduler.scheduleAtFixedRate({ refresh() }, 0, interval, TimeUnit.SECONDS)
    }

    private fun refresh() {
        val health = CachlyApiClient.fetchHealth()
        val text = when (health.status) {
            "not_configured" -> "🧠 Cachly: not configured"
            "unreachable"    -> "🧠 Brain: offline"
            else             -> "🧠 Brain: ${health.lessons} lessons"
        }
        SwingUtilities.invokeLater {      // MUST be on the EDT
            currentText = text
            statusBar?.updateWidget(ID()) // triggers repaint
        }
    }

    override fun dispose() = scheduler.shutdown()
}

Three rules you cannot skip

1. SwingUtilities.invokeLater — always update UI on the EDT. Without it you get random ConcurrentModificationException crashes that are impossible to reproduce.
2. ScheduledExecutorService over Timer — more robust, handles exceptions without killing the thread.
3. statusBar?.updateWidget(ID()) — tells IntelliJ the text changed. Without it the status bar won’t repaint.

Step 5: The API client (zero dependencies)

object CachlyApiClient {
    private val gson = Gson()

    fun fetchHealth(): BrainHealth {
        val s = CachlySettings.getInstance().state
        if (s.apiKey.isBlank() || s.instanceId.isBlank())
            return BrainHealth(status = "not_configured")

        val base = s.apiUrl.trimEnd('/')
        val id   = s.instanceId

        val instJson = httpGet("$base/api/v1/instances/$id", s.apiKey)
            ?: return BrainHealth(status = "unreachable")
        val inst = gson.fromJson(instJson, InstanceResponse::class.java)

        val memJson  = httpGet("$base/api/v1/instances/$id/memory", s.apiKey)
        val mem      = memJson?.let { gson.fromJson(it, MemoryResponse::class.java) }
                       ?: MemoryResponse()

        val totalRecalls = mem.topLessons.sumOf { it.recallCount }
        return BrainHealth(
            lessons            = mem.lessonCount,
            totalRecalls       = totalRecalls,
            estimatedTokensSaved = totalRecalls * 1200,
            tier   = inst.tier ?: "unknown",
            status = if (inst.tier != null) "healthy" else "degraded",
            topLessons = mem.topLessons,
        )
    }

    private fun httpGet(url: String, apiKey: String): String? = try {
        val conn = URI(url).toURL().openConnection() as HttpURLConnection
        conn.connectTimeout = 5000
        conn.readTimeout    = 5000
        conn.setRequestProperty("Authorization", "Bearer $apiKey")
        conn.setRequestProperty("Accept", "application/json")
        if (conn.responseCode == 200) conn.inputStream.bufferedReader().readText()
        else null
    } catch (_: Exception) { null }
}

Step 6: The detail dialog

class BrainHealthDialog(project: Project, private val health: BrainHealth)
    : DialogWrapper(project, false) {

    init { title = "🧠 Cachly Brain Health"; init() }

    override fun createCenterPanel(): JComponent {
        val panel = JPanel(BorderLayout(0, 12))
        panel.preferredSize = Dimension(700, 500)

        val summary = """
            <html><h2>Brain Overview</h2><table cellpadding="4">
              <tr><td><b>Lessons:</b></td><td>${health.lessons}</td></tr>
              <tr><td><b>Recalls:</b></td><td>${health.totalRecalls}</td></tr>
              <tr><td><b>Tokens Saved:</b></td><td>~${health.estimatedTokensSaved}</td></tr>
            </table></html>
        """.trimIndent()

        val columns = arrayOf("Topic", "Outcome", "Recalls", "What Worked")
        val data = health.topLessons.map { l ->
            arrayOf(l.topic, l.outcome, l.recallCount, l.whatWorked)
        }.toTypedArray()

        panel.add(JLabel(summary), BorderLayout.NORTH)
        panel.add(JScrollPane(JTable(data, columns)), BorderLayout.CENTER)
        return panel
    }
}

Yes, it’s Swing in 2026. But DialogWrapperintegrates perfectly with IntelliJ’s look and feel — themes, button placement, and keyboard shortcuts are all handled for you.

Step 7: Build and install

./gradlew buildPlugin
# → build/distributions/cachly-brain-0.2.0.zip

# Install: Settings → Plugins → ⚙️ → Install Plugin from Disk → select ZIP

Build gotchas

JDK 17+ is required for IntelliJ 2024.1 targets. Gradle 8.10+ for Platform Plugin 2.x. The first build downloads ~800 MB of IntelliJ SDK — be patient. Set untilBuild = "252.*" in your plugin config to stay compatible with upcoming releases.

VS Code vs. IntelliJ: side-by-side

AspectVS Code ExtensionIntelliJ Plugin
LanguageTypeScriptKotlin
Build toolvsce package./gradlew buildPlugin
Status bar API5 linesFactory + Widget class
Settings storagegetConfiguration()PersistentStateComponent
Detail UIMarkdown documentSwing DialogWrapper
Dependencies0 (built-in http)1 (Gson)
Package size~15 KB~50 KB
First build time2 seconds3 min (SDK download)
UI thread ruleNo concernMUST use invokeLater

The VS Code version took ~2 hours. IntelliJ took ~4 hours, mostly wrestling with Gradle plugin configuration and understanding the Factory pattern for status bar widgets.

The satisfying result

You open IntelliJ and see 🧠 Brain: 42 lessons in the bottom-right corner. Click it — a dialog opens with every lesson your AI ever learned: what worked, what failed, how many times each was recalled.

There’s something uniquely satisfying about seeing your own plugin in an IDE you use 8 hours a day. Not a web app in a browser tab. Inside your tool. Part of your workflow.

Install both plugins. VS Code and IntelliJ. Check your brain health. See every lesson your AI has ever learned — directly in your editor.