Plugin structure
A .lia file is a ZIP archive with a _sig entry containing LIATIR/1, a manifest.json and runtime payloads. Signed bundles also contain signature entries. Liatir verifies a supplied signature before executing the payload. R and Native keep this format and contract version 1; existing Node, Python and WASM bundles remain supported.
Runtime variants
There are five supported runtimes: Node, Python, R, Native, and WASM. All five declare the I/O contract once in code, using their language SDK or the Native executable protocol — and liatir build generates the bundle manifest schema from it. The field types and file/result values are the shared Liatir contract, regardless of the execution language. There is no parallel runtime-specific I/O schema to keep in sync by hand:
- Node declares it with
definePlugin({...})from@liatir/api. - Python declares it with
define_plugin(...)from the CLI-managedliatirmodule scaffolded next to the entry point. - R declares it with
define_plugin(...)from the CLI-managedliatir.R. - Native emits a code-declared contract through its executable; the Rust and C++ SDKs share their field builders and validation with WASM.
- WASM uses the managed Rust or C++ library and the same code-declared contract.
Node plugins have no .lia-manifest.json at all (metadata lives in package.json). Python, R, Native and WASM projects keep a small .lia-manifest.json for metadata (name, version, description, category, tags) and their runtime specs (Python/R dependencies or Native executable variants). Legacy Python/WASM plugins that still declare inputSchema/outputSchema in the manifest keep building; when a code contract exists, it wins and the manifest schema is ignored.
Generated Node manifest
For Node plugins, the manifest is generated by liatir build after bundling and validating the default export.
Manifest example:
{
"name": "my-plugin",
"version": "1.0.0",
"description": "What this plugin does",
"runtime": "node",
"category": "Utilities",
"tags": ["text"],
"inputSchema": {
"text": {
"type": "string",
"label": "Text",
"description": "Field description.",
"required": true,
"default": "hello from Liatir"
}
},
"outputSchema": {
"length": {
"type": "number",
"label": "Length",
"description": "Field description.",
"format": "integer"
}
}
}Node entry point (index.ts or .js)
Keep the entry point clean and do not alter its structure, or Liatir will reject the plugin. Organize your source files in the project's src/ folder and keep the entry point as a thin wrapper that exports the plugin logic.
import { definePlugin, field, type PluginContext } from "@liatir/api";
const liatirPlugin = definePlugin({
inputs: {
text: field.string({
label: "Text",
description: "Text to analyze.",
required: true,
default: "hello from Liatir",
}),
},
outputs: {
length: field.number({
label: "Length",
description: "Number of characters in the input text.",
format: "integer",
}),
},
});
export default liatirPlugin.main(async ({ input, Liatir }: PluginContext<typeof liatirPlugin>) => {
// Your logic
return {
length: input.text.length,
};
});liatir build rejects Node plugins that export an invalid contract.
Field schemas
Input fields can be of type:
stringnumberbooleanfile
Output fields can be of type:
stringnumberbooleanfilestatsjson
Shared field properties include:
| Property | Description |
|---|---|
label | Human-readable label shown in the UI. |
description | Short explanation shown near the field. |
required | Whether the field must be set before running. |
default | Default value applied by the UI and by liatir dev. |
accept | Accepted file extensions for file inputs. |
ext | Expected file extensions for file outputs. |
format | Numeric output display hint: integer, decimal, percent, or bytes. |
File outputs
If an output field has type: "file", the returned value can either reference an existing file path or provide inline content for Liatir to persist.
return {
report: {
content: "sample,score\nA,0.92\n",
fileName: "report.csv",
},
};Liatir registers saved file outputs under the workspace's Results folder so they can be opened later or connected to downstream pipeline steps.
WASM contract and manifest
WASM plugins declare the contract in src/main.rs with the CLI-managed src/liatir.rs module (liatir init --wasm scaffolds both, liatir build keeps the module in sync):
mod liatir;
use liatir::{define_plugin, field};
use serde_json::json;
fn main() {
define_plugin()
.input("text", field::string()
.label("Text")
.required(true)
.default_value("hello from Liatir"))
.output("length", field::number()
.label("Length")
.integer())
.main(|ctx| {
let text = ctx.str("text")?;
Ok(json!({ "length": text.chars().count() }))
});
}.lia-manifest.json only carries metadata:
{
"name": "wasm-length",
"version": "1.0.0",
"description": "Count characters in text.",
"runtime": "wasm",
"category": "Utilities",
"tags": ["text"]
}liatir build compiles the crate, reads the contract back from the binary, and generates the bundle manifest schema from it. At run time the SDK validates the input before your handler runs and the output after it returns; stdout is reserved for the result JSON (use eprintln! for Rust logs). Legacy tools without the SDK keep working with inputSchema/outputSchema in the manifest.
C++ WASM build
liatir init my-plugin --wasm --cpp supplies a CMake project with build: { "language": "cpp" } and runtime: "wasm" in the source manifest. Set WASI_SDK_PATH to an installed WASI SDK, then run liatir build. The CLI compiles for WASI Preview 1, extracts the code-declared contract and packages plugin.wasm using the existing bundle format. The C++ library validates inputs and outputs like the Rust library. Native bridge code and libcurl are excluded; output is plain JSON on stdout and errors use stderr with a nonzero exit status. The supplied C++ library does not require WASM exception support.
Python contract and manifest
Python plugins declare the contract in the entry module with the CLI-managed liatir module (liatir init --python scaffolds src/liatir.py next to src/main.py; liatir build keeps it in sync and ships it in the bundle):
from liatir import define_plugin, field
plugin = define_plugin(
inputs={
"text": field.string(
label="Text",
required=True,
default="hello from Liatir",
),
},
outputs={
"length": field.number(label="Length", format="integer"),
},
)
@plugin.main
def main(ctx):
return {"length": len(ctx.input["text"])}The handler receives a context with the validated input (defaults applied, required and typed fields checked); the returned dict is validated against the declared outputs. .lia-manifest.json carries metadata and the Python runtime spec:
{
"name": "python-length",
"version": "1.0.0",
"description": "Count characters with Python.",
"runtime": "python",
"python": {
"entry": "src/main.py",
"pythonRequirement": {
"minVersion": "3.10",
"maxVersionExclusive": "3.13",
"label": "Python >=3.10,<3.13"
},
"packages": [],
"requirements": []
}
}When the packaged .lia runs, Liatir creates an isolated managed Python runtime for that plugin bundle and installs the declared packages or requirements there. Legacy plugins with a plain main(input) function and a manifest-owned inputSchema/outputSchema keep working.
R contract and manifest
The entry point uses source("liatir.R"), define_plugin(...) and plugin$main(handler); see the complete R example. The source .lia-manifest.json contains metadata and R requirements:
{
"name": "r-length",
"version": "1.0.0",
"runtime": "r",
"r": {
"entry": "src/main.R",
"minVersion": "4.1",
"packages": []
}
}The CLI supplies src/liatir.R, prepares the author library and extracts the contract without running the SDK handler. The first build creates renv.lock; subsequent builds restore it. Declared dependencies, including Bioconductor packages such as bioc::limma, must be present in the lock. R has no legacy manifest-owned schema fallback.
For this project the ZIP payload contains:
_sig
manifest.json
r/
main.R
liatir.R
renv.lock
_prepare.RThe generated manifest has runtime: "r", r.entry: "r/main.R", r.lockfile: "r/renv.lock" and the code-derived inputSchema/outputSchema. Sibling source files are included under r/ too. Installed R libraries are prepared separately on the user's machine; they are not copied into the bundle. Install R >= 4.1 before building. The CLI finds Rscript on PATH or uses LIATIR_RSCRIPT; the desktop app also discovers standard macOS and Windows installations. R is optional for app startup and unrelated Plugins. Liatir does not currently download the R interpreter.
Package preparation
The author library lives in .liatir/r-build-library. Commit renv.lock with the project. If declared packages are missing from an existing lock, the build fails. To change package versions, deliberately update that author library and run renv::snapshot() with the explicit package list, including the SDK's jsonlite dependency, before rebuilding.
On the Plugin's page, Prepare R packages restores the lock into a separate library keyed by Plugin name/version, lock and interpreter identity. Preparation may need network access and platform compilation tools; prepared runs work offline. Code-only changes reuse the library. Development sessions use separate libraries that are removed when the session ends.
Native contract and manifest
Native Plugins contain compiled executables, not shared libraries. The Rust and C++ starters use the same field declarations as WASM and adds full bridge access. Its source .lia-manifest.json can be as small as:
{
"name": "native-length",
"version": "1.0.0",
"runtime": "native",
"native": { "protocol": 1, "cargo": true }
}cargo: true tells the CLI to compile the host executable. To package executables built separately, use native.targets, whose values are paths relative to the project. It can also add targets alongside a Cargo build:
{
"native": {
"protocol": 1,
"targets": {
"macos-aarch64": "bin/macos-aarch64/tool",
"linux-x86_64": "bin/linux-x86_64/tool",
"windows-x86_64": "bin/windows-x86_64/tool.exe"
}
}
}Each executable must emit a version 1 code contract. The CLI introspects host executables and generates <executable>.contract.json with their contract and SHA-256. For another target, copy its executable and its generated metadata from a build on that target. A stale hash or unequal contracts across targets fails the build. Do not write a separate schema by hand.
For a C++ project, the source manifest selects the author-side CMake build:
{
"name": "cpp-length",
"version": "1.0.0",
"runtime": "native",
"build": { "language": "cpp" },
"native": { "protocol": 1 }
}liatir init --native --cpp supplies this project, the CMake recipe and managed src/liatir.hpp / src/liatir_bridge.hpp headers. liatir build refreshes the library, compiles the host executable and feeds it through the same contract extraction and packaging path. Extra native.targets use the same generated cross-target metadata. The build section is author configuration and is not included in the generated runtime manifest.
The resulting payload contains manifest.json and executable entries such as:
native/macos-aarch64/tool
native/linux-x86_64/tool
native/windows-x86_64/tool.exeThe generated manifest rewrites native.targets to these bundle paths and adds inputSchema/outputSchema. The app selects an exact target; it does not fall back to a different processor. Supported names combine macos, linux or windows with aarch64 or x86_64. Only include targets you have actually built and tested.
Native permission is a separate desktop action bound to the complete bundle's SHA-256. Importing or verifying a signed bundle does not grant permission to run its executable. The permission receipt is tied to the complete bundle's SHA-256; any changed bundle requires permission again. liatir dev runs the local project you explicitly opened in its temporary session.
Package required runtime libraries in a self-contained executable or document system prerequisites. The current protocol packages executable variants; it does not install a separate set of native shared libraries.
Executable protocol, version 1
The managed Rust and C++ libraries implement this protocol for Native Plugins:
When
LIATIR_EMIT_CONTRACT=1is set, print one line and exit successfully:text__LIATIR_CONTRACT__{"liatirContract":1,"inputs":{"file":{"type":"file","required":true}},"outputs":{"length":{"type":"number"}}}Generate this from the same declarations used to validate execution. Do not perform analysis or bridge calls during introspection.
During execution, read the UTF-8 JSON object from
LIATIR_INPUT_FILE. The app creates this private file for the Job; it avoids command-line size limits and quoting differences. The Rust SDK also accepts stdin when run directly.Validate input values, run the analysis, validate outputs, and print exactly one
__LIATIR_RESULT__line followed by the JSON output object. Use ordinary stdout or stderr lines for diagnostic output. Exit nonzero on failure.The working directory and extracted executable are temporary and are removed when the Job ends. Return file content as
{ "content": "...", "fileName": "..." }, or use bridge-managed cache/data storage and return an absolute path. Do not return a path inside the temporary working directory. The standard Liatir result handler saves and associates file outputs with their run.The app injects
LIATIR_JOB_IDandLIATIR_IPC_FILE. Use them for the bridge.
Bridge requests use the same authenticated app bridge as Node, Python and R. This executable protocol transports the existing Plugin I/O contract; it does not define another set of field or result types.
Run and result lifecycle
All five runtimes share pipeline inputs and outputs, Jobs and Results. R and Native receive a private input JSON file and execute in a per-Job temporary directory. The app removes that directory at termination, including cancellation and failures. Return inline content or persistent bridge-managed paths for file outputs.
R libraries are prepared for the Plugin identity, dependency lock and interpreter; Native uses an executable for the exact host target. Neither adds a dependency to unrelated Plugins or app startup. See runtime preparation.
For R and Native payloads, extraction rejects unsafe archive paths, symlinks and duplicate entries, restores Unix executable permissions and enforces limits of 512 MiB compressed and 1 GiB expanded. Test an executable on every target you include; a successful run on one platform does not verify another platform.