Gå til hovedinnhold

Plugin System

neokapi plugins are manifest-driven, signed, out-of-process executables. The default kapi binary is Apache-2.0 and links zero vendor-plugin code; everything beyond the open-source core — cloud sync, the Okapi filter bridge, third-party formats — ships as a separate binary that kapi discovers on disk and dispatches to at runtime.

This page is the developer-facing overview. AD-007: Plugin System holds the full design rationale; the Plugin model note covers the complementary in-process side — how the Go code inside a plugin binary wires its features into the shared cli.App.

The manifest

Every plugin's directory contains a manifest.json declaring its identity (plugin, version, binary, license, min_kapi_version, group) and the capabilities it provides:

{
"manifest_version": "1",
"plugin": "myplugin",
"version": "1.4.0",
"binary": "kapi-myplugin",
"license": "Apache-2.0",
"min_kapi_version": "1.0.0",
"capabilities": {
"commands": [...],
"mcp_tools": [...],
"formats": [...],
"tools": [...],
"source_connectors": [...],
"schema_extensions": [...]
},
"daemon": {
"idle_timeout_seconds": 300,
"handshake": { "type": "stdio-handshake", "fields": ["socket", "version"] }
}
}

The daemon block appears only when a plugin declares formats, tools, or source connectors (the Mode-C transport, below). The canonical Go types live in core/plugin/manifest/manifest.go, and the embedded JSON Schema at core/plugin/manifest/schema.json.

kapi reads every manifest at startup and builds dispatch tables from them. There is no name fall-through and no $PATH lookup — a capability dispatches only if a manifest declares it.

Discovery

Plugins are discovered structurally by location, in precedence order:

OrderLocationPurpose
1 (highest)$KAPI_PLUGINS_DIR (:-separated; ; on Windows)Dev / CI / sandbox
2$XDG_DATA_HOME/kapi/plugins/ (~/.local/share/kapi/plugins/)kapi plugin install target
3system roots (/opt/homebrew/share/kapi/plugins/, /usr/local/share/kapi/plugins/, /usr/share/kapi/plugins/)OS package managers

Within each location, every direct entry that resolves to a directory containing a manifest.json is a plugin — a symlink counts, which is what makes the system roots work: a package manager stages the plugin inside its own package prefix and links it into the shared root (Homebrew links /opt/homebrew/share/kapi/plugins/<plugin> to the formula's keg). First-match-wins on plugin name. Two different plugins declaring the same capability is an error — kapi prints both manifests and refuses to dispatch the conflicting capability. A consolidated dispatch cache at $XDG_CACHE_HOME/kapi/plugins-cache.json skips manifest parsing when no discovery root has changed.

Three transport modes

A plugin declares one or more capability sections; kapi picks the transport per capability type.

  • Mode A — one-shot subprocess (commands). kapi forks <binary> command <name> [args] once per invocation, inheriting stdio and propagating the exit code. No state survives across calls.

  • Mode B — session subprocess (mcp_tools). kapi spawns <binary> mcp-server once per kapi mcp session and proxies tool calls over MCP-over-stdio.

  • Mode C — daemon over Unix socket (formats, tools, source_connectors). kapi spawns <binary> daemon; the plugin binds a Unix-domain socket and prints one JSON handshake line on stdout, then serves gRPC on the socket:

    {"socket":"/tmp/kapi-daemon-myplugin-12345.sock","version":"1.4.0"}

    kapi opens a gRPC client to that socket and dispatches concurrent requests. The daemon stays alive until kapi exits or hits its idle timeout (per-manifest, default 5 min). Concurrent daemons are capped via KAPI_MAX_DAEMONS (default 8) with LRU eviction. Format and tool capabilities register into the standard FormatRegistry / ToolRegistry and are indistinguishable from native ones at the API level. The Okapi bridge is the canonical Mode-C plugin — see Okapi Bridge.

The host-side runtime — discovery, dispatch, the daemon pool, the registry client, and signature verification — lives in cli/pluginhost/.

Declaring a plugin dependency

A kapi.yaml recipe declares the plugins it needs as a map of name → semver constraint:

version: v1
name: my-app
requires:
myplugin: "^1.0"
okapi-bridge: ">=1.47.0"

Loading the recipe fails if a named plugin is not registered. On a TTY, kapi offers to install it and retries; in CI it prints an actionable error pointing at kapi plugin install.

A recipe can also imply a plugin without naming it: declaring a top-level key that a plugin's schema_extensions own (server:, from the platform plugin) is the declaration. Typing one of that plugin's verbs without it installed gets the same offer rather than an unknown-command error — prompt on a terminal, install under --yes, then run the verb; both install routes plus exit code 2 in CI or under --quiet. A mistyped verb is left alone, keeping cobra's suggestions. See AD-007.

Lifecycle commands

kapi plugin list # show installed plugins
kapi plugin install <name> # download + verify signature + register
kapi plugin install <name>@<version> # pin a specific version
kapi plugin update <name> # upgrade to latest matching constraint
kapi plugin remove <name> # uninstall
kapi plugin info <name> # show manifest details
kapi plugin search <query> # list registry candidates
kapi plugin verify <name> # re-check sha256 + signature
kapi plugin update-index # refresh the cached registry index
kapi plugin rebuild-cache # force a rebuild of the plugin dispatch cache

kapi plugin install resolves the plugin from a registry — a JSON index served over HTTPS that maps plugin → versions → per-platform tarball URL, SHA-256, and a cosign certificate identity. Tarballs are cosign-signed via Sigstore keyless OIDC; install verifies the SHA-256 and the signing certificate against the registry-pinned identity before unpacking. Unsigned plugins refuse to install without --unsafe.

Standard plugins

  • A platform plugin — cloud-server sync (push / pull / auth), distributed separately on its own license terms. It demonstrates how a separately-licensed plugin attaches over the manifest model without re-licensing kapi: installed via its own Homebrew formula, which drops its binary into share/kapi/plugins/<plugin>/.
  • okapi-bridge — a JVM-backed Mode-C daemon exposing the Okapi Framework's filter library to neokapi. See Okapi Bridge.
  • On-device ML sidecars — cgo plugins that run native ML in their own subprocess so the heavy stack (onnxruntime, whisper.cpp, PDFium, ffmpeg) never enters the portable kapi binary: kapi-sat (segmentation), kapi-vision (OCR/layout), kapi-asr (speech-to-text), kapi-av (audio/video), and kapi-pdfium (PDF).

For on-device LLM text generation (translation, chat, QA, brand-voice), kapi drives a local Ollama runtime rather than bundling an inference engine: Ollama already runs GGUF models on the GPU (Metal/CUDA) and is managed through kapi models ollama and --provider ollama — a free, private alternative to the paid cloud providers. In the browser, the Core Framework lab runs a local model via WebGPU instead, since a web page cannot reach a local daemon.

A minimal Go reference plugin in examples/plugins/hello/ covers Mode A + B with no third-party dependencies.

Retiring a plugin

When a plugin is superseded (for example, kapi-llm was retired once the built-in Ollama provider replaced the bundled on-device engine), kapi marks it retired rather than silently breaking. Because a plugin binary installed from a previous version can still be sitting on disk — and the registry may be offline or pinned — retirement is signalled in two places:

  • A compiled-in tombstone (cli/pluginhost/tombstones.go) is the offline-authoritative source. It records the plugin name, the kapi version that retired it, the reason, and the replacement. Its mere presence in a build is the version gate: an older kapi simply has no entry. This is what makes retirement trustworthy with no network and with the binary still installed.
  • A registry deprecated field on the plugin entry is the faster, reversible, network-side signal. It lets the registry refuse new installs and flag the plugin in search without waiting for a kapi release. When the two disagree, the built-in tombstone wins for load-time enforcement.

A retired plugin stays listed but inert: kapi registers none of its dispatch routes (commands, formats, segmenters), so it is never loaded or run, and it contributes no models to kapi models. kapi plugins list shows it as retired with the reason and replacement; kapi plugins install refuses to (re)install it. kapi never auto-deletes software on disk — instead, kapi plugins prune removes retired user installs after confirmation (--yes, --dry-run), prints the OS command for system (Homebrew) installs rather than touching them, and leaves downloaded model caches and configuration alone.

See also