* feat(plugins): add keybindings and modal window capabilities
Add two independent plugin UI capabilities.
Keybindings: plugins declare keybindings in the manifest under
ui.keybindings ({id, default, description}), validated backend-side
(combo grammar + unique ids, requires a UI bundle) and exposed to the
frontend. Plugin JS binds a handler via registry.registerKeybinding(id,
handler); a global dispatcher fires it. Combos are user-overridable in
Settings > Keyboard Shortcuts (namespaced plugin:{pluginId}:{id}) with
conflict detection against core and other plugins.
Modals: host.openModal({title?, content, size?, dismissible?}) returns a
{ close() } handle and renders in a single host-owned Dialog isolated by
PluginErrorBoundary, cleaned up on plugin unload.
Fixture plugin, PLUGIN-API.md / spec, and a Playwright e2e demonstrate a
keybinding handler opening a modal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): address review feedback on keybinding dispatch and validation
Core shortcuts now take precedence over plugin keybindings on a colliding
combo (defaultPrevented cooperation between the two capture-phase
dispatchers), a throwing plugin handler no longer aborts dispatch to other
plugins, and the keybinding combo grammar is tightened: the backend validator
and frontend parser both drop the undocumented control/super modifier
aliases and reject shift+digit/symbol combos that can never actually fire
(KeyboardEvent.key reports the shifted character, not the token).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): stop conflicting core shortcut from double-firing with plugin keybindings
The earlier defaultPrevented guard only covered the central useAppShortcuts
dispatcher. Per-component core shortcuts registered via useKeyboardShortcut
run in the bubble phase, after the capture-phase plugin keybinding dispatcher,
so a plugin-handled-and-prevented combo still triggered the core action too.
Guard useKeyboardShortcut (and use-editor-keybinds, another capture-phase core
listener) on event.defaultPrevented so exactly one action fires per combo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): make core shortcuts always win over plugin keybindings
Panel-search, task-switcher, and other core listeners never checked
event.defaultPrevented, so a plugin bound to the same combo (e.g. Cmd/Ctrl+F)
could still fire alongside them. Instead of patching every core listener,
usePluginShortcuts now computes the set of effective core shortcut combos
(registry, resolved through user overrides, plus FIND_IN_PANEL/SAVE) and
skips invoking any plugin handler whose combo is in that set, so the
matching core listener always handles the event exactly once.
Also adds a hook-level regression test for useEditorKeybinds' existing
defaultPrevented guard, and fixes a leaked capture-phase window listener in
use-keyboard-shortcut.test.ts that could bleed into later tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): keep Cmd+K closing the command panel after shortcut-precedence guard
cmdk's built-in vim bindings intercept Ctrl/Cmd+K and +P as list-navigation
shortcuts and call preventDefault() before the event reaches our window-level
useKeyboardShortcut listeners. Once that hook started bailing on
event.defaultPrevented (added to fix core/plugin shortcut double-firing), the
palette's own Cmd+K close/reopen toggle got silently suppressed whenever focus
was inside the open panel. Disable cmdk's vim bindings on the command palette
so its Ctrl/Cmd+K and +P always reach the app-level toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(plugins): document keybindings and modal window plugin APIs
Fill in the public author-facing docs that the internal plugin-keybindings
and modal-window contract (PLUGIN-API.md, spec.md) already covers: the
registry.registerKeybinding / host.openModal JS surface, the ui.keybindings
manifest field with its combo grammar, and a capability mention on the
Plugins overview page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 KiB
title, description, status
| title | description | status |
|---|---|---|
| Plugins | Install and manage kandev plugins: Go backends kandev spawns and supervises, with an optional native frontend bundle. | experimental |
Plugins
Plugins extend kandev without forking core: a plugin ships a Go backend that kandev spawns and supervises as a subprocess over a strict typed gRPC protocol, and can optionally ship a native frontend bundle that kandev loads into the SPA. This page covers what plugins are, how to install and manage them, and the current security posture. To discover and install plugins from the in-app catalog, see the Plugin marketplace. For building a plugin, see Authoring a plugin. For the manifest schema, see Plugin manifest reference.
Plugins are an operator-level, instance-wide capability — there is no
per-user plugin access. They are gated behind the plugins feature flag
(Settings > System > Feature Toggles), off by default in production
builds and on by default in dev/e2e builds. Enabling the flag requires a
backend restart and surfaces Settings > Plugins in the sidebar.
How it works
Kandev owns the whole process lifecycle: it extracts the package, spawns the
binary, completes the go-plugin handshake, health-checks it (Ping every
30s), and restarts it on crash or repeated health-check failure (backoff,
max 5 attempts). There is no separate operator-managed plugin process to run
or babysit — install a package and kandev does the rest.
A native UI bundle can register a nav item that renders as a top-level sidebar entry or, when it declares itself part of the Integrations section, alongside kandev's first-party integration links in the main sidebar's Integrations section — expect new entries to appear there once such a plugin is installed and active. Bundles can also inject components into host-defined slots, including icon buttons in the chat composer toolbar (beside the model picker, mic, and send button), so an active plugin can add its own action right where you message an agent. A bundle can also declare keybindings (user-overridable at Settings > Keyboard Shortcuts, with core shortcuts always winning on a conflict) and open host-owned modal windows from anywhere in its code — see Authoring a plugin for both.
Global Status contributions
Native UI bundles can also add compact, live status UI on every hosted route:
function StatusContribution({ slotProps }) {
return host.jsx(
"span",
null,
`${slotProps.presentation}: ${slotProps.activeTaskId ?? "no task"}`,
);
}
registry.registerComponent("app-status-bar-left", StatusContribution);
registry.registerComponent("app-status-bar-right", StatusContribution);
Each registration is one opaque item in Kandev's 24 px desktop/tablet status bar
and phone Status drawer. The slot chooses its default side; users can Cmd/Ctrl plus
mouse-drag items across the desktop spacer, and Kandev preserves their order in
backend user settings. Phone shows the saved left sequence followed by the right
sequence, with no drag ordering. slotProps includes
placement, presentation, density, pathname, activeWorkspaceId,
activeTaskId, and activeSessionId. Only one presentation mounts at once;
adapt each contribution for both compact bar and touch-friendly drawer use.
Kandev does not inspect or reorder children inside a contribution, and disabled
plugins return to their saved position when re-enabled.
Full-bleed routes that opt out of host topbar chrome own their Status trigger.
Installing a plugin
The easiest way to install is from the in-app catalog — Settings > Plugins >
Browse, then Install on a card (see the Plugin
marketplace). To install a plugin that is not in a
configured catalog, open Settings > Plugins and click Install plugin.
You can install from a URL (kandev downloads the tarball) or by uploading a
.tar.gz file directly. No credentials are ever shown or copied — installing a
plugin has nothing to reveal, unlike a webhook-secret/API-key registration
flow.
The same operations are available over HTTP:
# Install from a URL
curl -X POST http://localhost:38429/api/plugins/install \
-H 'Content-Type: application/json' \
-d '{"url": "https://example.com/acme-tools-1.0.0.tar.gz"}'
# Install by uploading a local tarball
curl -X POST http://localhost:38429/api/plugins/install \
-F "package=@acme-tools-1.0.0.tar.gz"
Either path runs the same pipeline:
- Verify
checksums.txtcovers every other file in the tarball and every hash matches (always enforced). - Check for
checksums.txt.sig. Signature verification is not currently wired up, so every package — signed or not — installs and is reported as unsigned today (see "Signed vs. unsigned packages" below). - Parse and validate
manifest.yamlbefore any code runs: schema,idpattern, thecategoriesand UI-surface enums, and thatruntime.executablescontains an entry for the host's OS/arch. - Extract to
~/.kandev/plugins/<id>/<version>/and record the installation. - Spawn the platform-matched binary and complete the go-plugin handshake.
Status is
registeredwhile this is pending,activeonce the handshake succeeds, orerrorif spawn/handshake fails (the operator can retry via Enable).
A successful install that failed to spawn returns HTTP 201 with a
warning field rather than failing outright — the package is installed,
just not yet running.
Once installed, the plugin appears in the list with its category, a status
badge (active), a signing badge (unsigned today), and Disable and
Uninstall actions:
Filesystem sideload and Sync
Besides install-by-URL/upload, an operator with shell access to the host can
place plugin content directly under ~/.kandev/plugins/ without going
through the install endpoint. The Sync button in Settings > Plugins (and
POST /api/plugins/sync) reconciles kandev's registry with what is actually
on disk:
- A dropped directory —
~/.kandev/plugins/<id>/<version>/manifest.yamlplaced manually with no existing record — is validated and registered with statusdisabled, never auto-enabled. Directory sideloads skip the checksum/integrity gate the URL/upload pipeline runs, so an operator must explicitly inspect and enable one. If more than one version directory exists for the same unregistered id, the lexically greatest version is registered and the others are reported as skipped. - A dropped tarball — any
*.tar.gzsitting directly in~/.kandev/plugins/— is run through the same verified install pipelinePOST /api/plugins/installuses. On success the tarball file is deleted; on failure it is left in place and the failure is reported. - A missing install — a registered record whose
install_pathno longer exists on disk — is stopped (if running) and markederror.
At boot, kandev runs only the directory-sideload and missing-install steps (never the tarball-install step), as part of resuming plugins that were already active. This is conservative by design: starting up never spawns a binary an operator hasn't explicitly approved via install or Sync.
Enable, disable, uninstall
- Disable stops the subprocess. Config and state are preserved; no events or webhooks are delivered while disabled.
- Enable respawns the subprocess and re-completes the handshake.
- Uninstall stops the subprocess and deletes the plugin's package, registration record, and all persisted state — there is no grace period.
Per-plugin settings
A plugin can declare config_schema in its manifest, generating a settings
form at Settings > Plugins > <plugin> (also GET /api/plugins/{id}/config
and PATCH /api/plugins/{id}). Fields marked secret: true or
format: "password" (for example a GitHub PAT) are never returned in
cleartext to the operator UI — reads show a masked placeholder, and
resubmitting the form unchanged leaves the stored secret alone. The plugin
process itself receives the real values via the GetConfig Host RPC.
Saving config restarts the running plugin so it re-reads its config.
<id>.config.yml on disk is written with mode 0600 and may hold vault
references rather than cleartext for secret fields.
A plugin can also render its own UI inline on this page — at the top, above the
settings form — via the plugin-settings slot, for example a live
integration-health card ("CLI installed ✅ v0.45.2", "API token ✅
authenticated"). This is owner-scoped, so a plugin's card only ever appears on
its own settings page. See Named
slots in the authoring guide.
Signed vs. unsigned packages
Every package's checksums.txt is verified at install time — this integrity
gate is always enforced. Signing (checksums.txt.sig, an ed25519 signature
over checksums.txt) is a separate, optional layer, and its verification
hook is not currently wired up in the shipped product: no signature is
cryptographically checked today, so every install — signed tarball or not —
is currently treated and reported as unsigned. A signed package installs
identically to an unsigned one; signing is not required in v1.
On-disk layout
~/.kandev/plugins/
├── <id>.yml # registration record (status, install_path, signed, ...)
├── <id>.config.yml # operator-editable config (PATCH /api/plugins/{id})
└── <id>/
├── <version>/ # extracted package (InstallPath)
│ ├── manifest.yaml
│ ├── server/plugin-<goos>-<goarch>[.exe]
│ └── ui/bundle.js # optional
└── data/ # KANDEV_PLUGIN_DATA_DIR — shared across versions
Security posture
- Auth is the spawn relationship. Kandev spawns the plugin subprocess
itself over a unix domain socket (macOS/Linux) or loopback TCP (Windows) —
never a routable network address — and secures it in both cases with the
go-plugin handshake plus AutoMTLS. There is no
api_key,webhook_secret, or HMAC signing anywhere in the contract. - Capability-based access control. A plugin can only call the Host RPCs
it declared in its manifest:
stategates the state RPCs,secretsgates the plugin-owned secret RPCs, and each read-only data accessor (tasks, sessions, workspaces, workflows, agent profiles, repositories) is gated individually viaapi_read:<resource>(write access,api_write:<resource>, is reserved but not yet implemented). An undeclared capability returns gRPCPermissionDeniedwith a message naming the missing capability, checked by a server interceptor before the handler runs.GetConfigandEmitEventare the only ungated RPCs — a plugin can always read its own config (secrets included) and emit events. - Native UI plugins run in-origin with full app-store access. This is an
accepted tradeoff, not an oversight: a plugin bundle shares the kandev
React instance and Zustand store so it can build UI indistinguishable from
first-party pages. In v1 only active, operator-installed plugins load;
a failing bundle or
initializeis caught and never breaks boot; slot components render behind error boundaries. Hard sandboxing (a worker or realm boundary) is explicit future work — see below. - Package integrity is always checked; signing is optional. See "Signed vs. unsigned packages" above.
- Curated marketplace, no auto-install. The Plugin marketplace adds one-click install from a catalog, but the official source is PR-curated (a plugin appears only after a maintainer approves it), install is always an explicit operator action, and updates require an explicit click — there is no automatic discovery or background install. kandev collects no download or usage telemetry.
Related: Authoring a plugin, Plugin manifest reference, and Extending Kandev.



