← Reference

Plugin runtimes

Extension surfaces registered into plugin-host.

Full notes from the app

Every session builds its own plugin stack: plugins from the built-in root (~/.kcode/plugins-builtin — kcode-managed, refreshed every boot), ~/.kcode/plugins, and <project>/.kcode/plugins load into the session's live tool registry, MCP servers come from .mcp.json, and existing plugin dirs hot-reload on change. Headless (kcode -p) gets the same stack. A plugin bundle can also ship content dirs alongside its runtime — all five domains: <plugin>/commands/*.md and <plugin>/skills/*.md load into the slash-command and skill registries, <plugin>/agents/*.md into the agent-kind registry, <plugin>/output-styles/*.md into the output-style picker, and <plugin>/themes/*.ron|*.json into the theme catalogue. Every domain loads after the built-in bundle and the configured user/project sources, so those win a name collision, and a plugin blocklisted in <project>/.kcode/policy.toml contributes no content at all; commands/skills/themes hot-reload with the rest of the plugin, agents and output styles apply to newly-built sessions. Authoring is a guided path: kcode plugins scaffold <name> [--kind lua|wasm] [--project] emits a complete working skeleton (manifest, runtime entry demonstrating tools + event hooks + kv storage, a bundled command and skill); kcode plugins validate <path> then runs the same checks a session load runs — manifest shape, a real runtime load (Lua syntax errors, duplicate tools, missing Wasm ABI), frontmatter parses — and exits non-zero on failure. Worked examples live in examples/plugins/ in the repo. Local trust: a manifest declares the host APIs it uses in a [capabilities] table (kv = true, hooks = true; durable storage / event hooks — tool registration is never gated), and may declare named schedules — [[triggers.cron]] (six-field cron, seconds first) and [[triggers.every]] (plain durations: "30s", "5m") — validated at load (hooks grant required, unique names, periods parse, cron expressions checked by kcode plugins validate and again at registration); each firing publishes an owner-attributed core.timer.tick event the plugin hears via kcode.on_event("core.timer.tick", …), and a plugin.toml edit hot-reloads schedules with the rest of the plugin. A project can pin its plugin surface with <project>/.kcode/policy.toml (blocklist + required). The Claude-Code compat plugin stack (CC marketplace, name@marketplace installs, CC hooks) was removed (Decision 16, 2026-08-16): kcode consumes nothing from Claude Code's plugin ecosystem — it is its own hackable platform with the kcode marketplace as the sole name authority. (CLAUDE.md discovery and .claude/rules/ stay — those are kcode's own convention-sharing via agents-md, not the CC plugin stack.)

8 entries · kcode 0.4.0

8 shown
SurfaceLibraryInputScopeDescription
Luaplugin-runtime-luaLua scripts (main.lua + plugin.toml)Embedded — tools + events + kv + notify + inject + fetch + fs-read + input transforms + run-tool + subagentsEmbedded sandboxed Lua VM (os/io/require stripped). kcode.register_tool(name, desc, handler) in main.lua registers a tool into the session. kcode.on_event(kind, handler) subscribes a handler to session trigger events — core.fs.change (the project watcher, gitignore-filtered), core.timer.tick (config eventsource_timer.crons broadcast to all; plugin-owned schedule fires are owner-attributed and heard only by their own plugin), and the turn lifecycle (core.app.turn-started / core.app.turn-finished / core.app.turn-error); the handler receives {kind, source, intent, data}, every handler for a kind runs on one fire, and each run's completion (or Lua error) surfaces on the /control drawer as <cp:hook-fired>. kcode.kv_get(key) / kv_set(key, value) / kv_delete(key) / kv_list([prefix]) give the plugin durable storage that survives sessions — any JSON-serializable value, namespaced per plugin host-side (one plugin can never read another's keys), stored in the project's .kcode/storage.db for project plugins and ~/.kcode/storage.db for user plugins; usable at the top of main.lua and inside handlers alike. kcode.notify(title[, body]) posts a message to the user's durable notification center attributed to the plugin ("<title> — <body> (via <plugin>)"), escalating to a macOS banner when the window is unfocused — rate-capped per session (session_daemon.agent_notify_*, default 5 per 60s). kv_* requires the kv capability, on_event the hooks capability, and notify the notify capability (declared in plugin.toml); an ungranted call fails with an error naming the plugin and the missing permission. Fast, low overhead. P-A/P-B/P-D (2026-07-28): kcode.register_tool(name, desc, handler, opts?) accepts opts.schema (a JSON input schema — honest /tools + permission classification) and opts.read_only = true. kcode.fetch(url) (grant net) is a host-mediated GET — deliberately liberal (user decision 2026-07-28): loopback/private hosts allowed, 64MB body cap, 600/min per plugin, 120s timeout — returning {status, body, headers}. kcode.fs_read(path) / kcode.fs_list([prefix]) (grant fs-read) read the project read-only through path-guard (no .., no absolute paths, no symlink escapes; listings respect gitignore). kcode.ask_user(question-or-spec) poses the host's user-question modal mid-tool (string or {question, options?, fields?}; interactive sessions only). kcode.settings.get(key) resolves the plugin's declared [settings] schema (manifest default overlaid with the user's [plugins.<name>] config). kcode.on_input(handler) (grant input-transform) runs on every prompt before the turn: return nil to pass, {text=…} to rewrite, or {veto="reason"} to block the turn with a chat-visible, plugin-attributed error; the chain runs in plugin load order, ≤32 plugins × 30s, fail-open. core.app.assistant-output joins the on_event kinds (observe-only: the settled, sanitized assistant text per message). kcode.on_cron(spec, handler) (grant hooks, 2026-08-12) registers a schedule computed at load — e.g. from a setting — where spec is a plain period ("30s", "5m") or a six-field cron expression; the handler alone receives that schedule's fires (same {kind, source, intent, data} shape), a bad spec fails the load naming the schedule, and fires run plugin handlers only — they never start agent turns. kcode.emit(kind, data?) (grant hooks, 2026-08-12) publishes a custom event other plugins hear via on_event("plugin.<emitter>.<kind>", …) — the kind and source are both plugin:-prefixed so an emission can never impersonate a system event and every subscriber sees which plugin spoke; the emitter's own handlers are echo-suppressed, fires carry Observe intent (never an agent turn), and a 120/min per-plugin cap trips with a loud error rather than a silent drop. kcode.inject(text) (grant inject, 2026-07-29) wakes the owning session with an agent-visible injection: the text lands on the <cp:plugin-inject> channel — an in-flight turn sees it next round, an idle session is driven awake with it — so a watcher plugin can speak for itself instead of waiting for the user; empty text is dropped, delivery is fire-and-forget. core.app.tool-started / core.app.tool-finished join the on_event kinds (2026-08-12, observe-only: every tool invocation's start/finish pair — tool name, a bounded args preview, and on finish the outcome (ok/error/cancelled/vetoed) with duration). kcode.on_tool(handler) (grant tool-gate, 2026-08-12) is a blocking pre-tool gate: before every tool invocation the handler receives {tool, args} and returns nil to allow or {veto="reason"} to block — the call then fails with a plugin-attributed error ("plugin x vetoed tool t: reason"), never confusable with the session permission engine's own denials. Gates run in plugin load order, the first veto wins, each plugin gets 5s, and every failure mode (handler error, timeout, gone plugin) fails open — the permission engine stays the security boundary. kcode.run_tool(name, args?) (grant run-tool, 2026-08-12) runs any session registry tool on the plugin's behalf: every call goes through the session permission engine with the plugin named as the caller (plugin:<name> — rules and asks govern plugin-driven calls exactly like the agent's own, and a parked ask names the plugin waiting), then through the registry's normal invoke path, so the core.app.tool-started/core.app.tool-finished events and other plugins' gates cover it too (the caller's own gate is skipped — its single-threaded task is mid-call). Returns {structured, truncated} — the tool's structured JSON, the same value the model would see; a denial, veto, tool error, or unknown tool raises a catchable Lua error with the reason. kcode.spawn_subagent({prompt, kind?, name?}) (grant spawn-subagent, 2026-08-12) starts a background subagent of kind (default general; the kind's frontmatter model or the /subagent pool resolves the model, same as agent-driven spawns) — fire-and-observe: the call returns {name} at once, where name is the attributed child name (plugin:<plugin> or plugin:<plugin>:<label>) shown in the subagent panel and /jobs, and completion arrives as the subagent-finished trigger event carrying that same name (with success, final_text, rounds, error); an unknown kind or a rate-cap trip (5 spawns per 600s per plugin) raises a catchable Lua error, and /jobs kill terminates a runaway child. core.app.subagent-started / core.app.subagent-finished join the on_event kinds (2026-08-12, observe-only: every spawn's lifecycle pair, name-correlated). webhook:<name> joins the on_event kinds (2026-08-12): an authenticated POST to the daemon's loopback webhook listener (session_daemon.webhook_*) or a kcode trigger <name> fire — data carries {name, text, json?}, json riding when the body parses, so a Lua handler can read structured payloads without a JSON decoder. kcode.register_command(name, summary, handler) (2026-08-12, ungated like tool registration) adds a user-facing slash command: /name args runs handler(args) on the plugin's owning task daemon-side and renders the returned markdown (a string, {markdown=…}, or nil for silence) straight into the chat as a bubble — never a model turn. The slash menu lists the rows tagged plugin with the summary (or "from the <plugin> plugin"); a built-in, skill, or markdown-command name always wins the dispatch, and between plugins the first-loaded one answers. Registering the same name twice in one plugin fails the load; names are lowercase letters, digits, -, _. kcode.log(message[, level]) (2026-08-13, ungated — observability, not capability) writes a plugin-tagged log line: levels trace/debug/info/warn/error (default info; an unknown level is a catchable error naming the valid set). Each line lands in the app log under target kcode::plugin and in the plugin's bounded in-memory activity ring (cap 200 per plugin, oldest evicted) — the record behind the Settings › Plugins detail view, which also carries load/unload lifecycle markers and contained event/cron handler failures.
MCPplugin-runtime-mcpMCP servers from .mcp.json (stdio; http/sse declared)External — toolsModel Context Protocol clients. Declare servers in <project>/.mcp.json (Claude-Code-compatible command/args/env) or ~/.kcode/mcp.json; each server's tools register as <server>.<tool>. Stdio servers auto-connect at session spawn; http/sse entries parse but don't auto-connect yet.
WASMplugin-runtime-wasmWebAssembly (wasmtime)Sandboxed — tools + kv + notify + inject + fetch + fs-readWASM runtime on wasmtime with a documented linear-memory ABI (register()/invoke()). The kcode.kv_set/kv_get/kv_delete/kv_list imports mirror the Lua kv surface — durable per-plugin storage (JSON values, host-fixed namespace, project vs user store by plugin scope), callable from invoke() and requiring the kv capability in plugin.toml (an ungranted call traps with an error naming the plugin and the missing permission). A kcode.notify import mirrors the Lua notify surface — post a titled message to the user's notification center under the notify capability, plugin-attributed and rate-capped like any agent notify. Strict sandbox, ideal for untrusted extensions. The kcode.fetch and kcode.fs_read/fs_list imports mirror the Lua surfaces (grants net / fs-read; same guards, results via __alloc like kv_get). The kcode.inject import mirrors the Lua inject surface (grant inject): wake the session with an agent-visible <cp:plugin-inject> injection, plugin-attributed, fire-and-forget. Scope is deliberate (user decision 2026-08-12): Lua is the primary scripting surface — event hooks (on_event), the input transform, settings.get, and ask_user stay Lua-only; Wasm is for compute-heavy sandboxed tools, and gains those APIs only when a real Wasm plugin needs them. A kcode.log import mirrors the Lua logging surface (2026-08-13, ungated): log(level_ptr, level_len, msg_ptr, msg_len), level_len == 0 meaning info — plugin-tagged lines into the app log plus the per-plugin activity ring, legal from register() too.
Nativeplugin-runtime-nativeRust crate compiled into kcodeNative — toolsResolves plugins compiled into the binary via inventory. An internal extension seam — users cannot author these for a closed-source binary.
Contentplugin-hostContent dirs only (plugin.toml with kind = "Content")Runtime-less — commands/skills/agents/output-styles/themesA runtime-less bundle: no scripts, no tools — just content dirs. Loading it records the plugin (it shows in kcode plugins list and the session stack) and skips runtime dispatch entirely; its content loads through the same five domain channels every bundle gets. kcode's own built-ins ship this way: the kcode-essentials bundle (the four built-in skills, the four non-judge agent kinds, the two output styles) is materialized to ~/.kcode/plugins-builtin/kcode-essentials/ at every boot and its content loads before user/project sources, so built-ins keep winning name collisions — and a project that blocklists kcode-essentials in policy.toml runs without the built-ins entirely.
Capabilitiesplugin-host[capabilities] table in plugin.toml: on/off grants (kv, hooks, notify, input-transform, inject, tool-gate, run-tool, spawn-subagent, replace-builtin) and scoped lists ("net.http", "fs.read", "fs.write")All runtimes — host-API grantsA plugin declares the host APIs it uses in its manifest's [capabilities] table. Safe grants are on/off: kv for the durable kv_* storage family (Lua and Wasm), hooks for kcode.on_event, intercepts, and [[triggers.*]] schedules (Lua), notify for kcode.notify (Lua and Wasm), inject for kcode.inject (wake the session with an agent-visible <cp:plugin-inject> injection — joins an in-flight turn's next round or drives a wake turn when idle), run-tool for kcode.run_tool (run any session registry tool with the permission engine deciding each call and the plugin named as the caller), and spawn-subagent for kcode.spawn_subagent (start an attributed background subagent — plugin:<name>[:<label>] in the panel and the lifecycle events; rate-capped 5 spawns per 600s per plugin). The intercept powers are on/off too: input-transform for kcode.on_input (rewrite or veto the user's prompt before the turn starts — the most powerful grant; vetoes land in chat attributed to the plugin), tool-gate for kcode.on_tool (a blocking pre-tool gate that can veto any tool call with a plugin-attributed reason; fail-open on error or timeout, so the session permission engine stays the security boundary), and replace-builtin (shadow a safety-critical built-in — the bash + mutating shell_* set, fs_write/fs_edit/apply_patch — which also needs project trust and the install-review approve; the original stays reachable as builtin:<name>). Risky grants take a scoped list: "net.http" for kcode.fetch (host-mediated GET, 64MB cap, 600/min per plugin) names the otherwise-fenced hosts — loopback, private, metadata — the plugin may reach, and an empty list means any public host with the fenced class denied; "fs.read" for kcode.fs_read/fs_list and "fs.write" for project file writes list the allowed path globs (project-root-scoped via path-guard). Registering tools needs no grant — that's a plugin's baseline purpose, and what its tools do is governed by the session permission gate like any other tool call. An undeclared call fails with an error naming the plugin and the exact capability to add; an unknown key in [capabilities] fails validation, and a manifest still using the old permissions = [...] list is rejected with a pointer to [capabilities] (kcode plugins validate catches all of these before a session ever loads the plugin). /plugins lists each plugin's grants.
Project policyplugin-host<project>/.kcode/policy.tomlPer-project — block/require pluginsA committed-to-the-repo policy for the project's plugin surface: [plugins] blocklist = ["name"] refuses those plugins on every load path (discovery, hot reload, manual) — including their bundled content dirs (commands, skills, agents, output styles, themes), so a blocked plugin contributes nothing anywhere — and required = ["name"] flags any listed plugin or MCP server that isn't loaded. Violations surface in /plugins under a Project policy heading and as a startup notification. Unknown keys are a hard parse error (a typo'd rule must never silently unenforce); a malformed file never blocks plugins but is itself reported the same way.
Authoring CLIplugin-authoringkcode plugins scaffold / kcode plugins validateCLI — authoringscaffold <name> [--kind lua|wasm] [--project] emits a working skeleton into ~/.kcode/plugins (or <project>/.kcode/plugins): a manifest, a runtime entry that loads clean (the Lua one demonstrates a tool + an event hook + durable kv; the Wasm one is human-readable WAT text over the documented ABI), plus a bundled slash command and skill; it refuses to overwrite. validate <path> pre-flights any plugin dir with the exact checks a session load runs and exits non-zero on failure — fix before it ever reaches a session. Copyable examples: examples/plugins/ in the repo.