lithe-plugin-sdk-java is the official way to write a plugin backend in Java:
the process a plugin starts through a runtime launch
({ "kind": "runtime", "tool": "java", "minimumVersion": "8", "entry": "backend/app.jar" }).
It is the Java twin of the Node.js SDK. Both implement the
the following protocols from
plugin contract v1 with the
same rules and error mapping:
litheBuild1: typedlithe/build/modelandlithe/build/dependenciesrequests, for a build system whosemodelordependenciessource isprovider.litheFunctions1–4: plugin functions, which are plugin-private actions that Lithe invokes for the same plugin. They support progress, cancellation, and registration at runtime. From version 2 a backend may call host services; version 3 adds workspace, document, editor, and window services, command events, and workspace contexts; version 4 adds calls to other plugins' functions, which Core relays, and tells a function which plugin called it.litheKeymapImport1 (from 0.3.0): independently negotiated shortcut import providers reading only explicitly selected source snapshots.
The SDK requires Java 8 or newer and has no runtime dependencies. JSON values
are plain Map<String, Object>, List<Object>, String, Long, Double,
Boolean, and null (see Json).
Available from SDK 0.3.0. Declare backend.keymapImportProvider(id, handler) before listening. Its
handler receives a KeymapSnapshot with providerId(), snapshotId(),
listInputs() and readChunk(fileId, offset, maximumBytes), plus the normal
call context. Paths returned by listInputs are relative labels; reads use
opaque file IDs. Return a normalized { bindings: [...] } map and observe
the call's cancellation. The host revokes the snapshot when the call ends.
Do not turn conditional or multi-stroke rules into global bindings. Product
discovery and the import workflow are not available yet.
Backend backend = Backend.create("example-backend", "1.0.0");
backend.onModel((params, context) -> {
Map<String, Object> project = new LinkedHashMap<>();
project.put("name", "app");
project.put("path", Json.string(params, "root"));
Map<String, Object> model = new LinkedHashMap<>();
model.put("projects", Collections.singletonList(project));
return model;
});
backend.function("snapshot.refresh", (params, context) -> {
context.progress("Refreshing", 10.0);
Processes.Result result = context.run(Arrays.asList("example-tool", "refresh"));
if (result.exitCode() != 0) {
throw new PluginError("example.refreshFailed", "The refresh failed.");
}
return Collections.singletonMap("refreshedAt", System.currentTimeMillis());
});
backend.listen();examples/sample-build is a complete plugin. It has
a build provider, functions, persistent state, a function registered at
runtime, and cancellation. node scripts/sdk-e2e.mjs drives it through a real
Lithe Core session with the scenario in sdk/e2e,
the same one the Node.js sample answers.
Releases are published on this repository's Releases page and
tagged sdk-java-v<version>. Version 0.2.0 adds plugin-function protocols 2
and 3. Version 0.3.0 adds shortcut import providers, native terminals, and typed
diagnostics through litheLogging 1, and removes the free-text log API.
Version 0.4.0 adds plugin-function protocol 4: host().functions() for calls
to other plugins, CallContext.caller() for the plugin that called a function,
and HostError.pluginErrorCode(). The HostError fallback table, which names
a refusal by its number when the host sends no code, now also holds the host
error codes -32022 to -32026 and the plugin.function.* codes of protocol 4.
Version 0.1.0 was released from the Lithe
repository before the SDK moved here; its
release
stays available. A release ships
lithe-plugin-sdk-java-<version>.jar and a .sha256 file holding the JAR's
SHA-256. The SDK is not published to a Maven repository. Use it like this:
- Download the JAR during your plugin's build, never at runtime.
- Check the JAR against the SHA-256 you pinned in your repository.
- Shade it into your backend JAR, as the sample's
pom.xmldoes.
A plugin package is installed read-only and must run offline, so the backend JAR it starts must hold everything it loads.
From version 0.3.0, the SDK requires experimental.litheLogging version 1
at initialization and refuses a host that does not offer it, so a backend
built on 0.3.0 or later starts only in a Lithe release that offers it.
See plugin logging for the typed diagnostics
API, host-owned catalog, exact levels, operation correlation and ${logDir}.
Use diagnostics.degraded/recovered (Rust: diagnostics().degraded/recovered)
for handled fallback or recovery facts. Function and build/VCS terminal facts
are automatic; do not log a failure before returning it to the SDK. Suppress
successful high-frequency reads with suppressSuccessDiagnostics(operation)
(Rust: suppress_success_diagnostics). The old free-text log API is removed;
there is no stderr fallback or plugin-owned file writer.
| API | Purpose |
|---|---|
Backend.create(name, version[, capabilities]) |
Creates the backend. capabilities are standard LSP server capabilities; the SDK negotiates its experimental protocols. |
onModel(handler), onDependencies(handler) |
Implement the typed build requests. The SDK stamps format and version. Core validates the document. litheBuild is answered only when a handler exists. |
onExecute(handler) |
Protocol 2+: a bounded provider-owned task, returning { exitCode, output }. |
onTest(handler), onDiscoverTests(handler) |
Protocol 3+: exact execution and runnable selectors; discovery format/version are stamped. |
onPrepareRun(handler) |
Protocol 4: prepare a portable plan; the host owns the application. |
negotiated().buildVersion() |
Highest common build version (1–6), or zero; newer methods are refused before their handler runs. |
function(id, handler) |
Declares a function offered in initialize. |
registerFunction(id, handler), unregisterFunction(id) |
Add or withdraw a function. Called before the initialize answer (for example from onInitialize), they change the initial list. Later calls send lithe/function/register or lithe/function/unregister, in order. Both return a CompletableFuture. Withdrawing a function revokes its running calls. |
negotiated().functionsVersion() |
Highest common plugin-function version (1–4), or zero. |
host() |
Host services for work outside a call, such as a command handler. Its calls to other plugins name no parent call. |
onCommand(commandId, handler) |
Protocol 3: runs handler.handle(commandId, workspaceContext) when the user runs one of the plugin's commands. |
onContextsChanged(handler) |
Protocol 3: lithe/host/workspace/didChangeContexts with its { revision }. |
onHostNotification(method, handler) |
A lithe/host/* notification a later host adds. The three this SDK routes itself are reserved. |
onInitialize(hook), onShutdown(hook) |
Lifecycle hooks. onInitialize receives the initialize params and what was negotiated. |
onRequest(method, handler), onNotification(method, handler) |
Ordinary LSP methods. Lifecycle and lithe/* methods are reserved. |
sendRequest, sendNotification |
Ordinary LSP messages to the host. lithe/* methods are reserved. |
diagnostics().degraded(operation, code[, count]), .recovered(...) |
Typed handled fallback/recovery; no payload text. |
listen() |
Serves stdio until exit or the end of input, then exits the JVM with the protocol's exit code. listen(in, out, err) serves other streams and returns the code instead. |
PluginError(code, message) |
A failure meant for your own UI. |
Processes.run(command[, options]) |
Runs a process without a shell. Its stdin is closed. Options set the directory, environment, output limits, a cancellation, and output callbacks. |
StateStore.open(directory) |
A small atomic JSON store in a host-provided directory. |
Handlers run on their own threads. Every handler receives a CallContext:
| Member | Meaning |
|---|---|
cancellation(), isCancelled() |
Cancelled when any of these happens: Lithe cancels the call, its deadline passes, the function is withdrawn, the session ends, or the handler finishes. Finishing the handler stops the work it left running. |
throwIfCancelled() |
Throws CancelledException once the call is cancelled. |
run(command[, options]) |
Processes.run bound to the call: cancelling or ending the call stops the process. |
diagnostics() |
Typed diagnostics correlated with this call. |
functionId() |
Functions only. |
host() |
Host services bound to the call: ending the call cancels the requests it left unanswered. From protocol 4 its calls to other plugins name this call as their parent. |
workspaceContext() |
Protocol 3 functions: the { contextID, generation } of the window that invoked the call, or null. |
caller(), callerPluginId() |
Protocol 4 functions: the plugin that called this function through the relay, as an unmodifiable { pluginID } map and as its ID. Null when the host started the call or the session negotiated protocol 3 or earlier. |
operationId() |
Opaque wire identity for functions and bounded build/test/run-preparation calls; null for other requests. It falls back to the JSON-RPC request ID, so never use it as a parentOperationID. |
progress(message, percentage[, output]) |
Functions use 1024-byte messages. Build/test/run preparation use 4096-byte messages and optional output up to 64 KiB. Percentages are clamped to 0–100. Cancelled or settled calls cannot report. Model/dependency/discovery reports are ignored. |
The typed handlers of build protocols 2–6 ship from SDK 0.2.0. See native build workflows
and the no-page Java sample.
Manifest buildProviders requirements and the negotiated protocol both apply;
registering a handler never bypasses either boundary. Build tool failure is an
exit-code result; throwing an error aborts preparation.
Private function results and errors map exactly as in the Node.js SDK; typed build operations follow their domain contract:
- A returned value becomes
result.value. PluginErrorbecomesplugin.function.failedwith your message andpluginErrorCode.- Any other exception is reported with a generic message; automatic diagnostics keep a fixed failure category without its message, cause or stack.
CancelledExceptionends the call as cancelled.- A result larger than 1 MiB of JSON fails like an unexpected error.
From plugin-function protocol 2 a backend may ask the host for services;
each is granted by a manifest permission and refused otherwise. The SDK
negotiates the highest version both sides offer, so a backend written for
protocol 1 keeps working unchanged. Every method returns a
CompletableFuture; wrong arguments fail it rather than throwing.
backend.function("query.run", (params, context) -> {
// Protocol 2, permission credentials.store: the platform's secret store, per plugin.
String password = context.host().credentials().get("connection.prod:password").get();
if (password == null) {
throw new PluginError("query.noPassword", "Sign in first.");
}
// Protocol 3: the window the page runs in.
Map<String, Object> workspace = context.workspaceContext();
if (workspace != null && context.host().supports("lithe/host/workspace/getFolders").get()) {
Object folders = context.host().workspace().getFolders(workspace).get().get("folders");
// ...
}
return runQuery(params, password);
});
backend.onCommand("db.refresh", (commandId, workspaceContext) -> {
if (workspaceContext != null) {
backend.host().window().showInformation(workspaceContext, Collections.singletonMap("text", "Refreshed."));
}
});Host member |
Version | Purpose |
|---|---|---|
credentials().get(key), .set(key, secret), .delete(key) |
2 | Secrets in the platform's store. get completes with null when nothing is stored. A key is 1–128 of [A-Za-z0-9._:-], a secret at most 16 KiB; others fail locally. |
capabilities(), supports(method) |
3 | What this session may call, from lithe/host/getCapabilities (asked once). Under protocol 2, which has no discovery, supports reports the credential methods. |
workspace().listContexts(), .getContext(context), .getFolders(context) |
3 | Workspace contexts; only getContext returns, and creates, the context's stateDirectory. |
workspace().readDirectory, .stat, .readFile, .findFiles, .findText, .watch |
3 | Files of a local context, addressed as { rootID, path } from getFolders. |
documents().list, .getSnapshot; editor().getActive, .getSelections, .applyEdits, .replaceSelections, .insertText, .open, .reveal, .registerActions |
3 | Live documents and editors. Edits name the expectedVersion they were computed against. |
preview().openHtml, browser().openExternal, window().showInformation, .showWarning, .showError |
3 | Preview, browser, and messages. |
functions().providers(functionId), .invoke(context, call) |
4 | Calls to other plugins' functions, relayed by Core. |
request(method, params), subscribe(method, params, listener) |
2 / 3 | Any lithe/host/* method, including ones a later host adds. |
subscriptions().dispose(id) |
3 | Ends a subscription; Subscription.dispose() does the same. |
withCancellation(cancellation) |
2 | The same services, cancelled together with cancellation. |
Methods taking a workspace context accept { contextID, generation } or a
whole context from listContexts; their params are the contract's, without
context. The full params and results are in
the contract.
What the SDK does for you, and what it leaves to you:
- Refusals are
HostError. Branch oncode(), such asplugin.host.contextExpired; a code this SDK does not know is still aHostError, anddetails()holds the host's object. OnversionConflictreread and recompute; oncontextExpiredresolve the original target again, never the focused window; retrybusyandrateLimited(afterdetails().get("retryAfterMilliseconds")) with a bound;permissionDeniedandreadOnlyneed the user. From protocol 3 an unknown param isplugin.host.unsupportedOptionnaming it indetails().get("option"), so you can retry without it. - Cancellation has no rollback. Cancelling the token given to
withCancellation, or ending the call that sent a request, fails it at once withCancelledException; from protocol 3 the SDK also sends$/cancelRequestso the host stops. An edit or an external open may already have taken effect, and the SDK never sends a request again. Cancelling the returned future itself does not reach the host. - At most 16 host requests are unanswered. Further ones wait in the SDK for a free slot instead of being refused.
- Subscriptions route by ID.
workspace().watchandsubscribecall theListenerwith eachHost.Eventin order, one at a time;gap()is true when events were missed, so rescan. Nothing arrives afterdispose()or the end of the session. - Commands are events.
onCommandruns when the user runs one of the plugin's commands; the host waits for no result. A stopped backend is started for it only when the manifest declaresonCommand:<commandID>.
From plugin-function protocol 4 a backend may call a function that another
plugin exports. The plugin must declare the call in its manifest, and Core
relays it: the backend never talks to the other plugin. A plugin that declares
no call gets plugin.host.permissionDenied. The exports, the declaration and
the checks Core makes are in the
contract.
backend.function("project.sync", (params, context) -> {
Host.Functions functions = context.host().functions();
Object providers = functions.providers("languageImport.settings").get().get("providers");
// ... choose a provider ...
Map<String, Object> call = new LinkedHashMap<>();
call.put("pluginID", "dev.example.go");
call.put("functionID", "languageImport.settings");
call.put("params", Collections.singletonMap("project", "app"));
call.put("timeoutMilliseconds", 5000L);
// Core decides who may be called for which workspace; use the one the user's call ran in.
Object settings = functions.invoke(context.workspaceContext(), call).get().get("value");
return Collections.singletonMap("settings", settings);
});| Member | Purpose |
|---|---|
functions().providers(functionId) |
Completes with { providers: [{ pluginID, version }] }: the enabled plugins other than yours that export the function, sorted by plugin ID. You must have declared the function. |
functions().invoke(context, call) |
Completes with { value }, the provider's result (value may be null). context is a workspace context, { contextID, generation } or a whole one from listContexts; the call runs for that workspace. call is a map with pluginID and functionID, and optionally params (a map, default {}), timeoutMilliseconds (a positive integer; Core defaults it to 60 000 and caps it at 600 000) and parentOperationID. |
CallContext.caller(), .callerPluginId() |
On the providing side: the plugin that called this function, as { pluginID } and as its ID. Core writes it from the calling session. Null when the host started the call, and on sessions that negotiated protocol 3 or earlier. |
HostError.pluginErrorCode() |
For plugin.function.failed, the provider's own error code (from details.pluginErrorCode); null otherwise. |
What the SDK does for you, and what it leaves to you:
- The relay needs protocol 4. On a session that negotiated protocol 3 or
earlier,
providers,invoke, and arequestfor either method fail at once withHostErrorplugin.host.unavailable(-32601), the answer Core would give, and nothing is sent. Checknegotiated().functionsVersion()orsupports(method)first. - The parent call is filled in for you. Inside a function,
context.host()setsparentOperationIDto the wireoperationIDof the function call it belongs to, so Core knows which call the new one is a child of. The ID is used only when it is a string of 1 to 128 bytes; otherwise the field is omitted. The SDK never usesoperationId()for this, because it falls back to the JSON-RPC request ID. AparentOperationIDyou put incallwins.withCancellationkeeps the parent.Backend.host(), which belongs to no call, names none. Without a parent Core treats the new call as a child of every relayed call the backend is serving. - Wrong arguments fail locally. A malformed call completes the future
with
PluginSdkErrorsdk.functions.invalidCall(a missing or wrong workspace context issdk.host.invalidContext) and sends nothing: afunctionIDthat is not a function ID, an emptypluginID,paramsthat is not a map, atimeoutMillisecondsthat is not a positive integer, or aparentOperationIDthat is not 1 to 128 bytes of text. A null optional field counts as absent. Only the fields above are sent; any other key,callerincluded, is dropped, because Core refuses a caller-chosencaller. The SDK does not captimeoutMilliseconds; Core does. - Refusals are
HostError. Besides the host codes, protocol 4 addsplugin.function.notDeclared,.notExported,.providerDisabled,.versionMismatch,.callCycle,.depthExceeded,.tooManyCalls,.payloadTooLarge,.providerUnavailable,.failedand.invalidResult.plugin.function.failedcarries the provider's own code inpluginErrorCode(). A call to a context the host closed fails withplugin.host.contextExpired; a remote workspace withplugin.host.authorityUnsupported; a deadline withplugin.host.timeout. Core never retries a call, and neither does the SDK. - Calls end with their caller. Ending or cancelling the function call that
made a relayed call, or cancelling the token given to
withCancellation, fails the future withCancelledExceptionand sends$/cancelRequest, so Core cancels the provider's operation and the calls it made. - Limits are Core's. A chain has at most 4 calls, and a plugin already on
it, the caller included, cannot be called (
callCycle): a provider asked to apply values must not call the asker back inside that call. A caller has at most 8 relayed calls running; params and results are at most 256 KiB. Only local workspaces are supported. A relayed call holds one of the 16 unanswered host-request slots until it ends, which may be 600 seconds, so a backend that starts many long calls delays its other host requests. - Decide by
caller(), never by params. A plugin ID inparamsis data the caller wrote;context.caller()is the identity of the calling session.
These are the Node.js SDK's rules. In Java:
-
Domain capabilities stay typed. Build models and dependency trees go through
onModelandonDependencies, never through a function. -
No calls back into Lithe's dispatch.
sendRequestandsendNotificationrefuse anylithe/*method. Host services go throughhost(), which sends onlylithe/host/*. -
stdout belongs to the protocol.
listen()keeps the real stdout for protocol frames and pointsSystem.outat stderr. Never write toFileDescriptor.out. -
The package is read-only. Keep state under the directories Lithe passes through launch templates (
${stateDir},${workspaceStateDir}).StateStore.openrefuses a directory inside the plugin package. It finds the package by walking up from the SDK's own JAR toplugin.json. Diagnostics use the SDK and host; native log files require an explicitly supplied${logDir}on a compatible local host. -
Work ends with its call. Start processes with
context.run, and checkisCancelled()in long loops. On cancellation the SDK first asks the process to stop, then stops it forcibly after a grace period.- macOS and Linux, every Java version: each tool leads its own process
group, as in the Node SDK. The SDK starts it through the system
perl, which callssetpgrpand then becomes the tool. A stop reaches the whole group, and when the tool exits on its own, whatever it left running in its group is killed. - Windows, or no
perl: on Java 9 or newer the SDK stops the descendants still attached to the tool; Java 8 stops only the direct child.
A descendant that leaves the group (
setsid) or that the fallback cannot reach is ended by Lithe's process scope together with the backend. A cancellation listener that throws is logged and does not keep the other listeners or the reply from running.On
shutdown,exit, or the end of input the SDK ends every process it started.Processes.runkeeps Lithe's process-scope markers even whenOptions.environmentreplaces the environment. - macOS and Linux, every Java version: each tool leads its own process
group, as in the Node SDK. The SDK starts it through the system
-
Errors stay presentable. Throw
PluginErrorfor failures the user should see; every other exception is reported generically.
mvn -B -f sdk/java/pom.xml verifyThis builds the SDK and the sample for Java 8 with any newer JDK, then runs
the JUnit tests. Add -Djvm=/path/to/java8/bin/java to run the tests on a
Java 8 runtime, as CI does.
Build protocol 6 accepts the host's optional params.modelInputs snapshot.
A consumer declares exact modelInputs producer slots and requires
buildProviders: 6 plus buildModelInputs: 1. A producer opts into
modelSharing and selects any ordinary build option values it wants to share.
The host supplies normalized projects, targets and content roots; tasks, run
plans, settings and unselected option values are excluded.
Treat revision as an opaque workspace snapshot identity. Recompute cached
work when it changes. A slot can be unavailable, and an instance can be loading,
stale or failed; only ready instances carry facts. Do not invoke another plugin
or install/activate a missing producer. Input sharing grants no process or
private-function authority. Version 1 producers cannot consume and re-export
inputs. Core validates the shape and the host revokes in-flight work when its
producer changes. Protocol 1–5 handlers reject requests containing model inputs.
The two native workflow samples demonstrate independent producer and consumer backends in the Core end-to-end tests. Their engine is fictional; this does not migrate Maven, JDT, LLDB or existing plugin business logic.
From version 0.3.0, the SDK includes wrappers for plugin-owned native terminal create/show/close,
reopen with bounded display history, disposal and exit subscriptions. These
methods are discovered within protocol 3; they require a newer host and
terminal.create, not a new protocol version. See
the native terminal guide.