Plugins add translation engines, OCR, TTS, spell checkers, or dictionaries to QTranslate — without touching the app. You write a few classes, build a fat JAR, install it through the UI. That's the whole process.
The bundled plugins are better documentation than this guide. Open them before writing a single line:
| Plugin | What it shows |
|---|---|
plugins/google-services/ |
API keys with @field:Setting, dynamic language lists, multiple services in one plugin |
plugins/bing-services/ |
Token auth, chunking long text, static language map |
plugins/common/ |
HTTP client, JSON parsing, language mapper — copy these freely into your own plugin |
Start with GooglePlugin.kt → GoogleTranslatorService.kt. That covers 90% of what you need.
A single JAR can include any combination of these:
| Interface | Does |
|---|---|
Translator |
Translates text from one language to another |
TextToSpeech |
Converts text to audio |
OCR |
Extracts text from an image |
SpellChecker |
Returns spelling corrections |
Dictionary |
Definitions, synonyms, examples |
Summarizer |
Condenses text into a shorter form, configurable length |
Rewriter |
Rewrites text in a different style (Formal, Casual, Concise, Detailed, Simplified) |
my-plugin/
├── build.gradle.kts
├── settings.gradle.kts
└── src/main/
├── kotlin/com/example/myplugin/
│ ├── MyPlugin.kt
│ └── MyTranslatorService.kt
└── resources/
├── plugin.json
├── assets/icon.svg
└── META-INF/services/
└── com.github.ahatem.qtranslate.api.plugin.Plugin
settings.gradle.kts
rootProject.name = "my-plugin"
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://jitpack.io") // QTranslate API is published here
}
}build.gradle.kts
plugins {
kotlin("jvm") version "2.0.0"
}
group = "com.example"
version = "1.0.0"
dependencies {
// compileOnly — available at compile time, NOT bundled in your JAR.
// QTranslate provides this at runtime. Never use "implementation" here —
// it causes classloader conflicts and bloats your JAR.
compileOnly("com.github.ahatem:qtranslate-api:1.0.0")
// Your own dependencies go as "implementation" — they ARE bundled
implementation("io.ktor:ktor-client-core:2.3.7")
implementation("io.ktor:ktor-client-cio:2.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
// Fat JAR — bundles all your "implementation" dependencies into one installable file
tasks.jar {
manifest {
attributes["Plugin-Class"] = "com.example.myplugin.MyPlugin"
}
from(configurations.runtimeClasspath.get().map {
if (it.isDirectory) it else zipTree(it)
})
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}Which API version? Use the API version, not the app release version — they are independent. Check
ApiVersion.ktfor the current value. During development before any tagged release exists, you can use a full commit hash instead (e.g.abc1234).
Every plugin has exactly one Plugin class. It declares the plugin's identity and returns its services.
Without configuration:
package com.example.myplugin
import com.github.ahatem.qtranslate.api.plugin.*
class MyPlugin : Plugin<PluginSettings.None> {
override val id = "com.example.my-plugin" // permanent — never change after publishing
override val name = "My Plugin"
override val version = "1.0.0"
override val description = "Translates using the Example API."
override val icon = "assets/icon.svg"
override fun getSettings(): PluginSettings.None = PluginSettings.None
override fun getServices(): List<Service> = listOf(
MyTranslatorService()
)
override fun onInitialize(context: PluginContext) {
// context.logger — write to the app log
// context.scope — coroutine scope tied to plugin lifecycle
// context.storeValue / getValue — persistent storage, survives restarts
}
}With API key or settings:
import com.github.ahatem.qtranslate.api.plugin.PluginSettings
import com.github.ahatem.qtranslate.api.plugin.Setting
class MySettings : PluginSettings.Configurable() {
@field:Setting(
label = "API Key",
description = "Your key from example.com/developers",
isRequired = true
)
var apiKey: String = ""
@field:Setting(label = "Region")
var region: String = "us-east"
}
class MyPlugin : Plugin<MySettings> {
private val settings = MySettings()
override val id = "com.example.my-plugin"
override val name = "My Plugin"
override val version = "1.0.0"
override fun getSettings() = settings
override fun getServices() = listOf(MyTranslatorService(settings))
}@field:Setting on a var property is enough — QTranslate builds the settings dialog automatically. No UI code needed.
import com.github.ahatem.qtranslate.api.plugin.ServiceError
import com.github.ahatem.qtranslate.api.translator.*
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.Ok // Ok(...) and Err(...) are top-level functions,
import com.github.michaelbull.result.Err // not types — import them like this
import java.io.IOException
class MyTranslatorService(
private val settings: MySettings
) : Translator {
override val id = "com.example.my-plugin.translator"
override val name = "My Translator"
override val version = "1.0.0"
override val iconPath = "assets/icon.svg"
override val supportedLanguages = SupportedLanguages.All
// SupportedLanguages.Specific(setOf(LanguageCode("en"), LanguageCode("ar")))
// SupportedLanguages.Dynamic → override fetchSupportedLanguages() below
override suspend fun translate(
request: TranslationRequest
): Result<TranslationResponse, ServiceError> {
if (settings.apiKey.isBlank())
return Err(ServiceError.AuthError("API key not configured"))
return try {
val text = callMyApi(
text = request.text,
from = request.sourceLanguage.tag,
to = request.targetLanguage.tag,
apiKey = settings.apiKey
)
Ok(TranslationResponse(translatedText = text))
} catch (e: IOException) {
Err(ServiceError.NetworkError("Request failed: ${e.message}", e))
} catch (e: Exception) {
Err(ServiceError.UnknownError("Unexpected error: ${e.message}", e))
}
}
// Only implement this when supportedLanguages = SupportedLanguages.Dynamic
override suspend fun fetchSupportedLanguages(): Result<Set<LanguageCode>, ServiceError> {
return try {
val codes = fetchLanguagesFromApi(settings.apiKey)
Ok(codes.map { LanguageCode(it) }.toSet())
} catch (e: Exception) {
Err(ServiceError.NetworkError("Could not fetch language list", e))
}
}
}Return errors, never throw them. Use:
| Error type | When |
|---|---|
ServiceError.NetworkError |
Connectivity, timeout, DNS |
ServiceError.AuthError |
Invalid or missing API key |
ServiceError.RateLimitError |
Quota exceeded |
ServiceError.InvalidResponseError |
API returned unexpected format |
ServiceError.UnknownError |
Anything else |
Other service types (TextToSpeech, OCR, SpellChecker, Dictionary) follow the exact same pattern — implement the interface, return Ok/Err. See the Google plugin for a complete TTS and OCR example.
src/main/resources/plugin.json:
{
"id": "com.example.my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "Your Name",
"description": "Translates using the Example API.",
"minApiVersion": "1.0.0",
"icon": "assets/icon.svg"
}idmust match yourPluginclass exactly — this is your plugin's permanent identifierminApiVersion— use1.0.0unless you know you need something newericon— path inside your JAR resources
Create this file (the filename is the full interface name):
src/main/resources/META-INF/services/com.github.ahatem.qtranslate.api.plugin.Plugin
Contents — just your Plugin class fully qualified:
com.example.myplugin.MyPlugin
This is how QTranslate finds your plugin. No reflection, no scanning, just this one file.
./gradlew jarJAR is at build/libs/my-plugin-1.0.0.jar.
Install:
- Settings → Plugins → Install Plugin → select the JAR
- Enable the plugin
- Configure it if needed (Settings → Plugins → select → Configure)
- Assign it in Settings → Services & Presets
Faster dev loop — skip the install UI, copy directly:
# Windows
./gradlew jar && copy build\libs\my-plugin-1.0.0.jar "C:\path\to\appdata\plugins\"Then restart QTranslate. Much faster than reinstalling through the UI every time.
PluginContext — onInitialize gives you everything you need:
context.logger— writes to the app log file, visible in dev modecontext.scope— coroutine scope, cancelled when the plugin is disabledcontext.storeValue(key, value)/context.getValue(key)— persistent per-plugin storage
Never import :core or :ui-swing — your plugin must only depend on :api. If you need something that isn't in the API, open an issue.
Icons — SVG preferred, ~32×32. FlatLaf applies a colour filter when rendering icons in the plugin list — if you want your icon to adapt to dark/light themes automatically, use a single-colour SVG. If you provide a full-colour icon it will display as-is without filtering.
Don't log credentials — never log settings.apiKey or any secret. The log is visible to users.
Coroutines — service methods are suspend and run on Dispatchers.IO. Don't block the thread.
Plugin shows as "Failed"
Read the error in the plugin detail panel:
No Plugin implementation found via ServiceLoader→ yourMETA-INF/services/file is missing or has a typo in the class nameplugin.json is missing or could not be parsed→ file not atsrc/main/resources/plugin.json, or invalid JSONAPI version incompatible→ yourminApiVersioninplugin.jsonis newer than what the app supports, or the MAJOR version doesn't match. CheckApiVersion.ktfor the current API version and setminApiVersionto match
Services don't appear in Settings → Services & Presets
The plugin is enabled but the service isn't assigned. Open Settings → Services & Presets and select it from the dropdown.
ClassNotFoundException when the plugin runs
A class from one of your dependencies isn't in the JAR. This means the fat JAR task isn't picking up everything.
Check what's actually inside your JAR:
jar tf build/libs/my-plugin-1.0.0.jarYou should see .class files from your dependencies (e.g. io/ktor/...). If you only see your own classes, the fat JAR task isn't working.
Make sure your tasks.jar block includes runtimeClasspath — that's the configuration that holds all your implementation dependencies:
from(configurations.runtimeClasspath.get().map {
if (it.isDirectory) it else zipTree(it)
})Also check that your dependencies are declared as implementation, not compileOnly. Anything compileOnly is intentionally excluded from the JAR.
Settings dialog doesn't open
Your settings class must extend PluginSettings.Configurable and the annotated properties must be var, not val.
Unresolved reference: Ok / Unresolved reference: Err
Ok and Err are top-level functions from the kotlin-result library — they are provided transitively by the API dependency so you don't need to add kotlin-result to your own build.gradle.kts. You just need the imports:
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Result // for the return typeOnce it works: submit to the community list — it gets added to the README and wiki.