lithe-plugin-sdk is the official way to write a
plugin backend in Rust: the one
long-lived process a plugin declares under backend in plugin.json, which
Lithe starts on demand (a page's first call, a command, workspace open; see
Backend lifecycle and workspace contexts).
The backend is a packaged executable, so a plugin ships one archive per
platform. It speaks the LSP base protocol on stdio and implements, from the
plugin contract, which lives in this repository
next to the SDK and changes with it:
litheFunctions: plugin functions with progress, cancellation, and registration at runtime.- Host services: the plugin's own credentials, kept by the host in the
platform's secret store, workspace contexts, files,
documents, editors, preview, the browser, window messages, commands, and
subscriptions, and calls to the functions other plugins export,
relayed by Core, with the
Callera function called that way sees. litheKeymapImport: independently negotiated shortcut import providers reading only explicitly selected source snapshots.litheLogging: typed diagnostics, required at initialization; see Logging.
The SDK owns the wire format, negotiation, cancellation, and error mapping.
Your code supplies only handlers. It depends on serde_json and base64, runs
every call on a thread of its own, and needs Rust 1.87 or newer.
This SDK has no build provider or VCS provider API; write build providers with the Node.js or Java SDK, and VCS providers with the Node.js SDK.
The Lithe desktop declares backend and pluginFunctions (both version 0), so
the function and host-service protocols below run there. It does not declare
keymapImportProviders: a plugin whose manifest requires it is not offered for
installation there, although the Core end-to-end tests still drive the protocol.
Which host services a session may call is reported at run time; see
Host services.
Declare keymap_import_provider(id, handler) before listening. The host
negotiates IDs independently of plugin functions; a handler receives a
KeymapImportRequest and a call-scoped Context. Use
request.list_inputs(context.host()) and request.read_chunk(context.host(), file_id, offset, maximum_bytes) to read declared files in bounded chunks.
KeymapSourceFile.path is a relative label; the file ID is the authority.
Return { "bindings": [...] } in the normalized contract format. The host
cancels the call on import cancellation and revokes the snapshot when it ends.
Do not convert conditional or multi-stroke rules into global bindings.
"pluginAPIVersion": 0,
"requires": { "backend": 0, "ui": 0 },
"permissions": ["backend.launch", "credentials.store", "ui.contribute", "ui.web"],
"backend": { "launch": { "kind": "packaged", "path": "bin/backend" }, "arguments": ["--state", "${stateDir}"] }use lithe_plugin_sdk::{Backend, PluginError};
use serde_json::json;
fn main() {
let mut backend = Backend::new("example-backend").version(env!("CARGO_PKG_VERSION"));
backend
.function("connection.test", |params, context| {
context.progress("Connecting", Some(10));
let key = params["connection"].as_str().ok_or_else(|| {
PluginError::new("example.invalidParams", "\"connection\" must be a string.")
})?;
let password = context.credentials().get(&format!("connection:{key}"))?;
context.check_cancelled()?;
Ok(json!({ "hasPassword": password.is_some() }))
})
.expect("a valid function ID");
std::process::exit(backend.listen());
}The page calls it through the host bridge, without an instance:
const { value } = await lithe.request('functions.invoke', { function: 'connection.test', params: { connection: 'main' } });examples/sample-backend is a complete plugin:
the manifest, a web page, notes kept in ${stateDir} with progress, a
function registered at runtime, a secret in the credential store,
cancellation, a watch on the page's workspace, a command answered with a
message in its window, and a handler for workspace changes.
From the root of this repository,
node shared/plugins/scripts/sdk-e2e.mjs drives it through a real Core session
with the scenario in
sdk/e2e/plugin_sdk/backend.rs.
The SDK is released from this repository, tagged sdk-rust-v<version>; the
current release is
0.4.0.
The SDK is not published to crates.io. Either way below fetches it at build
time, never at runtime, and pins its exact contents:
-
Git dependency. Name the tag;
Cargo.lockrecords the commit, andcargo build --lockedrefuses anything else:[dependencies] lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/Lithe-core-rust", tag = "sdk-rust-v0.4.0" }
-
Vendored archive. A release ships
lithe-plugin-sdk-rust-<version>.crate(a gzipped tarball, ascargo packagemakes it) and a.sha256file. Check the archive against the SHA-256 you pinned, unpack it into your repository, and use it as apathdependency.
The SDK requires experimental.litheLogging at initialization and refuses a
host that does not offer it.
See plugin logging for the typed diagnostics
API, host-owned catalog, exact levels, operation correlation and ${logDir}.
Use diagnostics().degraded/recovered for handled fallback or recovery facts.
Function terminal facts are automatic; do not log a failure before returning
it to the SDK. Suppress successful high-frequency reads with
suppress_success_diagnostics. There is no free-text log API, no stderr
fallback and no plugin-owned file writer.
| API | Purpose |
|---|---|
Backend::new(name).version(version) |
Creates the backend; name and version are reported as serverInfo, and name prefixes log lines. |
backend.function(id, handler) |
Declares a function offered in initialize. |
backend.on_initialize(hook) |
Runs before the initialize answer with an Initialize: the host's params(), functions_version() (Some(0) when the host offered litheFunctions, otherwise None), root_path() (the plugin's ${stateDir}), and function(id, handler) to add functions that depend on state found at startup. An error refuses initialize. |
backend.on_shutdown(hook) |
Runs on shutdown, after running calls were cancelled. |
backend.handle() |
A clonable BackendHandle for other threads. |
backend.listen() |
Serves stdio until exit or the end of input and returns the exit code: 0 after shutdown and exit, 1 otherwise. |
backend.serve(input, output) |
The same on other streams, for tests. |
handle.register_function(id, handler), handle.unregister_function(id) |
Adds or withdraws a function after initialize and waits until the host accepted it. Withdrawing revokes and cancels running calls; a handler must not withdraw its own function. |
handle.function_ids(), handle.functions_version() |
What the backend serves, and Some(0) when the host offered litheFunctions (otherwise None). |
backend.on_command(id, handler) |
Runs Fn(&str, Option<WorkspaceContext>) when the user runs one of the plugin's commands. |
backend.on_contexts_changed(handler), backend.on_host_notification(method, handler) |
Handle lithe/host/workspace/didChangeContexts and any other lithe/host/* notification a later host adds. |
handle.host(), context.host() |
The host services. handle.host() is bound to no call. |
handle.credentials(), context.credentials() |
Shorthand for host().credentials(). |
PluginError::new(code, message) |
A failure meant for your own UI. |
A handler is Fn(Value, &Context) -> Result<Value, FunctionError>. Its
context offers:
| Member | Meaning |
|---|---|
cancellation() |
A Cancellation, fired when Lithe cancels the call, its deadline passes, the function is withdrawn, or the session ends: is_cancelled(), check(), wait_timeout(duration) (use it instead of thread::sleep), and on_cancel(callback), for example to cancel a running database query from the driver. |
check_cancelled() |
Err(Cancelled) once cancelled, for ?. |
progress(message, percentage) |
The message is cut to 1024 bytes and the percentage to 100; reports after the call ended are dropped. |
host() |
Host services bound to the call: a request still waiting for the host when the call is cancelled ends at once. credentials() is host().credentials(). |
workspace_context() |
The WorkspaceContext of the window whose page made the call; None when no window made it. |
caller() |
The Caller (plugin_id()) of the plugin whose backend called this function through Core's relay; None when the host started the call and when the host's caller is not exactly { pluginID } with a non-empty string. |
backend(), function_id(), diagnostics() |
The handle, the function's ID, and typed diagnostics correlated with this call. |
A backend may ask the host for services; each is granted by a manifest
permission and refused otherwise. They are available when the host offers
litheFunctions in initialize; without it, host requests fail locally with
plugin.host.unavailable and send nothing. Every method blocks its thread
until the host answers and returns Result<_, SdkError>; wrong arguments fail
locally and send nothing.
backend
.function("query.run", |params, context| {
// Permission credentials.store: the platform's secret store, per plugin.
let password = context
.host()
.credentials()
.get("connection.prod:password")?
.ok_or_else(|| PluginError::new("query.noPassword", "Sign in first."))?;
// The window the page runs in.
let mut folders = None;
if let Some(workspace) = context.workspace_context() {
if context.host().supports("lithe/host/workspace/getFolders")? {
folders = Some(context.host().workspace().get_folders(workspace)?);
}
}
run_query(¶ms, &password, folders)
})
.expect("a valid function ID");
let host = backend.handle().host();
backend
.on_command("db.refresh", move |_command_id, workspace| {
if let Some(workspace) = workspace {
host.window()
.show_information(&workspace, json!({ "text": "Refreshed." }))?;
}
Ok(())
})
.expect("a valid command ID");Host member |
Purpose |
|---|---|
credentials().get(key), .set(key, secret), .delete(key) |
Secrets in the platform's store; see Credentials. |
capabilities(), supports(method) |
What this session may call, from lithe/host/getCapabilities (asked once). |
workspace().list_contexts(), .get_context(&context), .get_folders(&context) |
Workspace contexts; only get_context returns, and creates, the context's stateDirectory. |
workspace().read_directory, .stat, .read_file, .find_files, .find_text, .watch |
Files of a local context, addressed as { rootID, path } from get_folders. |
documents().list, .get_snapshot; editor().get_active, .get_selections, .apply_edits, .replace_selections, .insert_text, .open, .reveal, .register_actions |
Live documents and editors. Edits name the expectedVersion they were computed against. |
preview().open_html, browser().open_external, window().show_information, .show_warning, .show_error |
Preview, browser, and messages. |
functions().providers(function_id), .invoke(&context, call) |
The plugins that export a function, and a call to one of them; see Calling other plugins. |
request(method, params), subscribe(method, params) |
Any lithe/host/* method, including ones a later host adds. |
subscriptions().dispose(id) |
Ends a subscription; Subscription::dispose() does the same. |
with_cancellation(&cancellation) |
The same services, cancelled together with cancellation. |
negotiated() |
true when the host offered litheFunctions in this session; false before initialize. |
A host registers only the services it implements, and capabilities() lists
exactly those; call supports(method) before depending on one of them. What
the Lithe desktop does not register yet is listed under
Capability versions.
Methods taking a workspace context take a &WorkspaceContext: one from
context.workspace_context(), WorkspaceContext::new(id, generation), or
WorkspaceContext::from_value on a whole context from list_contexts. Their
params are the contract's, without context, and they return the
contract's result as a Value. The full params and results are in
the contract.
What the SDK does for you, and what it leaves to you:
- Refusals keep the host's code. Branch on
code(), such asplugin.host.contextExpired;rpc_code()is the JSON-RPC number anddetails()the host's object, if any. A code this SDK does not know is kept as sent. OnversionConflictreread and recompute; oncontextExpiredresolve the original target again, never the focused window; retrybusyandrateLimited(after theretryAfterMillisecondsdetail) with a bound;permissionDeniedandreadOnlyneed the user. An unknown param isplugin.host.unsupportedOptionnaming it in theoptiondetail, so you can retry without it. - Cancellation has no rollback. Cancelling the token given to
with_cancellation, or ending the call whosecontext.host()sent a request, fails it at once withsdk.call.cancelled; 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. - 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().watch,editor().register_actions, andsubscribereturn aSubscription;recv()andrecv_timeout(duration)give eachEventin order, withsequence(),event(), andgap(), which is true when events were missed, so rescan. Events the host sends before its answer are kept. No event is delivered afterdispose()or the end of the session; once the queued ones are read,recv()returnsNone. - Commands are events.
on_commandruns on a thread of its own when the user runs one of the plugin's commands, with the window'sWorkspaceContextwhen the host sent one. The host waits for no result, and a failure is only logged. A stopped backend is started for it only when the manifest declaresonCommand:<commandID>. Host notifications without a handler are ignored.
A key is 1–128 ASCII letters, digits, ., _, :, or -; a secret is at
most 16 KiB. Credentials are private to your plugin ID, survive disabling and
updating the plugin, and are deleted when it is uninstalled. They are never
written to ${stateDir} or the package. get returns Ok(None) when
nothing is stored.
Failure (SdkError::code()) |
Meaning |
|---|---|
plugin.host.unavailable |
The host did not offer litheFunctions, so it has no host services. Nothing was sent. |
plugin.host.permissionDenied |
The plugin lacks the credentials.store permission. |
plugin.host.invalidParams, sdk.credentials.invalidKey, sdk.credentials.invalidSecret |
A malformed key or secret. |
plugin.host.failed |
The host's secret store failed, for example a locked secret store; the message says why and never contains the secret. |
sdk.call.cancelled |
The call was cancelled while waiting; ? answers as cancelled. |
sdk.session.notReady, sdk.session.closed |
The session has not finished initialize, or it ended while waiting. |
A backend may call a function another plugin exports; Core relays the call and
the provider's backend never learns more than the call it serves. The
manifest declares both sides: the caller lists { id, version } under
pluginFunctions.calls, and the provider lists
{ id, version, context: "workspace" } under pluginFunctions.exports and
registers the function with backend.function(id, handler). See
cross-plugin calls
for the manifest, the order of Core's checks, and the call chain.
backend
.function("project.options", |_params, context| {
let workspace = context
.workspace_context()
.ok_or_else(|| PluginError::new("example.noWorkspace", "Open a folder first."))?;
let host = context.host();
if !host.supports("lithe/host/functions/invoke")? {
return Ok(json!({ "options": [] }));
}
let providers = host.functions().providers("project.describe")?;
let Some(provider) = providers["providers"][0]["pluginID"].as_str() else {
return Ok(json!({ "options": [] }));
};
let answer = host.functions().invoke(
workspace,
json!({
"pluginID": provider,
"functionID": "project.describe",
"params": { "project": "app" },
"timeoutMilliseconds": 5000,
}),
)?;
Ok(json!({ "options": answer["value"] }))
})
.expect("a valid function ID");
backend
.function("project.describe", |params, context| {
// Core wrote the caller from the calling session; it cannot be forged.
let caller = context.caller().map(|caller| caller.plugin_id());
Ok(json!({ "project": params["project"], "askedBy": caller }))
})
.expect("a valid function ID");- Check before calling.
supports("lithe/host/functions/invoke")andsupports("lithe/host/functions/providers")tell whether this session may call. Host services alone are not enough: a plugin that declares nocallsgetsplugin.host.permissionDenied. When the host did not offerlitheFunctions,providers,invoke, andhost().requestwith either method fail locally withplugin.host.unavailableand send nothing. providers(function_id)answers{ "providers": [{ "pluginID", "version" }] }: the enabled plugins other than this one that export the function, sorted by plugin ID. This plugin must have declared the function, or the host answersplugin.function.notDeclared. A provider's backend need not be running.invoke(&context, call)answers{ "value": ... }, wherevalueis the provider's result and may benull.contextis aWorkspaceContextthis backend learned from the workspace service orcontext.workspace_context(); the relay accepts local workspaces only.callis an object withpluginIDandfunctionID, and optionallyparams(an object,{}by default),timeoutMilliseconds(a positive integer; the host defaults it to 60000 and caps it at 600000, and the SDK does not clamp it), andparentOperationID. Any other key, such ascontextorcaller, is not sent; the host would refuse it asplugin.host.unsupportedOption. A call that is not an object, lackspluginIDorfunctionID, or has a field of the wrong type fails locally withsdk.functions.invalidCall, and afunction_idthat is not a function ID does the same forproviders.- Limits are the host's. A chain has at most 4 calls and does not return
to a plugin already on it (
plugin.function.callCycle); a caller has at most 8 relayed calls running; params and a result are at most 256 KiB. A provider that is asked to apply values must not call the plugin that asked inside that call. parentOperationIDis filled in for you inside a handler. A call made withcontext.host().functions().invoke(...)while a function runs names that call'soperationID, so Core makes it a child: cancelling or ending the handler's call cancels the relayed call. An explicitparentOperationIDincalloverrides it (1 to 128 bytes). Ahandle.host()serves no call and a genericrequestis sent as written, so neither fills it in. An ID that names a call this backend is no longer serving fails withplugin.host.cancelled.- A relayed call occupies a request slot. It counts among the 16
unanswered host requests until the provider answers, which can take up to
600 seconds; further requests wait in the SDK. Keep relayed calls bounded
with
timeoutMilliseconds. context.caller()names the calling plugin. It isSome(Caller)only for a call Core relayed, andNonefor a call the host started. The SDK accepts only acallerthat is exactly{ "pluginID": "<non-empty string>" }; any other shape is read as no caller. It names who called, not what they may do: authorize with the plugin's own rules.
Failure (SdkError::code()) |
Meaning |
|---|---|
sdk.functions.invalidCall |
The call or the function ID was malformed. Nothing was sent. |
plugin.host.unavailable |
The host did not offer litheFunctions. Nothing was sent. |
plugin.host.permissionDenied |
The plugin declares no pluginFunctions.calls. |
plugin.function.notDeclared, .providerDisabled, .notExported, .versionMismatch |
The call is not declared, the provider is missing or disabled, does not export the function now, or exports another version. |
plugin.function.callCycle, .depthExceeded, .tooManyCalls, .payloadTooLarge |
The chain, the caller's running calls, or a payload exceeded a limit. |
plugin.function.providerUnavailable, .invalidResult |
The provider's backend stopped or did not start in time, or answered with no result or one over 1 MiB. |
plugin.function.failed |
The provider's function returned an error. The pluginErrorCode entry of details() is the provider's own PluginError code, and message() is its message. |
plugin.host.contextExpired, plugin.host.authorityUnsupported, plugin.host.timeout, plugin.host.cancelled |
The context was closed or replaced, is not a local workspace, the deadline passed, or the call (or its parent) was cancelled. |
sdk.host.invalidResult |
The host answered with something other than an object. |
| Handler outcome | What Lithe sees |
|---|---|
Ok(value) (including Value::Null) |
result.value |
Err(PluginError) |
plugin.function.failed with your message and pluginErrorCode. The code is for your plugin's own UI; Lithe never branches on it. |
| Any other error, or a panic | plugin.function.failed with a generic message, so paths, secrets, and stack traces never reach the UI. Automatic SDK diagnostics record only a fixed failure category. A panic ends only its call; SDK catch boundaries suppress raw payload printing. |
Err(Cancelled), or any outcome after cancellation |
requestCancelled (or Lithe's timeout or revocation outcome) |
? converts PluginError, Cancelled, SdkError, std::io::Error, and
serde_json::Error into FunctionError; FunctionError::internal(details)
wraps anything else. A result must serialize to at most 1 MiB of JSON. A
function ID is dot-separated lowerCamelCase segments (snapshot.refresh), at
most 128 bytes, and a session holds at most 256 functions. A PluginError
code matches [A-Za-z][A-Za-z0-9._-]*, at most 64 bytes; PluginError::new
panics on any other code.
- stdout belongs to the protocol. On Unix,
listen()moves the protocol to a duplicate of the stdout descriptor and points stdout at stderr, so a strayprintln!in your code or a dependency lands in the host's log. Elsewhere, never print to stdout. - The package is read-only. Keep caches and databases under
${stateDir}, which Lithe also passes as the root and working directory. Diagnostics use the SDK and host; native log files require an explicitly supplied${logDir}. Writing to the package breaks code signing and update deltas. - Secrets go to the credentials service. Never write a password or
token to
${stateDir}, to a log line, or to aPluginErrormessage. - Work ends with its call. Check the cancellation in long loops, use
wait_timeoutbetween retries, and register anon_cancelcallback for blocking driver calls. Lithe has already ended the call when it cancels; the SDK answersrequestCancelledwhatever the handler returns. - No calls back into Lithe's dispatch. The SDK never sends
lithe/function/invokeorlithe/build/*; Lithe would refuse them asplugin.function.recursiveCall.Host::requestsends onlylithe/host/*and refuses anything else assdk.host.method. To call another plugin, usehost().functions(), which sendslithe/host/functions/invokeand lets Core relay it.
The SDK includes wrappers for plugin-owned native terminal create/show/close,
reopen with bounded display history, disposal and exit subscriptions. They are
host methods discovered through capabilities(); they require a host that
offers them and the terminal.create permission. See
the native terminal guide.
Run these from the root of this repository:
cargo test --locked --manifest-path shared/plugins/sdk/rust/Cargo.toml
cargo fmt --manifest-path shared/plugins/sdk/rust/Cargo.toml --all -- --check
cargo clippy --locked --manifest-path shared/plugins/sdk/rust/Cargo.toml --all-targets -- -D warnings
node shared/plugins/scripts/sdk-e2e.mjsThe unit tests drive a session through in-process pipes with a scripted
host; plugin-sdk/tests/host_services.rs checks the host services against the
contract's fixture. The end-to-end check builds the sample, installs it as a
package, and runs it through a real Core session of this checkout. It needs
Rust, Node.js 20.3 or newer, Java (LITHE_PLUGIN_SDK_JAVA_RUNTIME or java on
PATH) and Maven; LITHE_PLUGIN_SDK_RUST_SAMPLE names an already built
sample-backend.