Writing kobe plugins
The developer-facing reference: everything a plugin can declare, every event kobe fires, every environment variable it injects, and every way to call back in. Design rationale lives in design/plugins.md and design/plugin-events.md; this page is the contract.
A plugin is a directory with a kobe-plugin.toml manifest plus any argv
commands your machine can run: Bash, Node, Bun, Python, Rust, a prebuilt
binary. There is no SDK: the whole kobe CLI and the daemon socket are the
plugin API. Kobe owns the host surface (install, validation, event dispatch,
env injection, panes, settings UI, run logs); you own the implementation.
Quickstart
mkdir my-plugin && cd my-plugin
cat > kobe-plugin.toml <<'EOF'
id = "you.hello"
name = "Hello"
version = "0.1.0"
min_kobe_version = "0.8.24"
[[events]]
on = "agent.turn-complete"
command = ["sh", "-c", "echo \"$KOBE_PLUGIN_TASK_TITLE finished a turn\" >> \"$KOBE_PLUGIN_STATE_DIR/log\""]
EOF
kobe plugin link . # register your working directory (dev loop)
kobe plugin log you.hello # inspect hook runs (exit codes, output, timing)Optional SDK (TypeScript)
The contract above is the API: any language, no SDK required. For
TypeScript/JavaScript authors, @sma1lboy/kobe-plugin-sdk wraps that
same contract with types and autocomplete (zero deps, Node ≥ 18 or Bun):
import { pluginContext, pluginEvent, notify, Pane, KobeSocket } from "@sma1lboy/kobe-plugin-sdk"
const ctx = pluginContext() // typed KOBE_PLUGIN_* env
const ev = pluginEvent() // typed event envelope (null outside [[events]])pluginContext()/pluginEvent(): the env contract, typed.readSettings()/setting(): your[[settings]]values from config.env.kobe()/kobeJson()+notify/dispatch/listTasks/openPane:$KOBE_BIN_PATHcallbacks.KobeSocket: daemon socket client:request(name, payload)+ live channelsubscribe(alwaysrole: "pane").Pane: a tiny pane kit for[[panes]]pages: alt screen, raw-mode keys, resize, absolute-addresseddraw(lines).PLUGIN_EVENT_NAMES/DAEMON_CHANNELS: the catalogs as typed unions. These are the SINGLE source: the daemon itself imports them from the SDK's./contractmodule, so host and SDK can't drift by construction.
Package README has full examples: packages/kobe-plugin-sdk/README.md.
Publish: push a public GitHub repo (one plugin per subdirectory is fine),
add the topic kobe-plugin → it appears in the marketplace
(kobe.sma1lboy.me/plugins and
kobe plugin search) automatically. Users install with
kobe plugin install owner/repo[/subdir] and stay fresh with
kobe plugin outdated / kobe plugin update --all (an update is a clean
reinstall of the managed checkout; config/state survive).
Manifest reference
id = "you.example" # letters/digits/dot/colon/underscore/hyphen
name = "Example"
version = "0.1.0"
min_kobe_version = "0.8.24" # install refuses older kobe
description = "…" # optional
platforms = ["macos", "linux"] # optional; item-level `platforms` overrides
[[build]] # runs at GitHub install (after preview confirm), cwd = checkout
command = ["npm", "install"] # self-provision deps INTO the plugin dir; `link` skips build
[[startup]] # once per daemon start, after the socket is ready; one-shot, not a daemon
command = ["node", "restore.js"]
[[actions]] # on-demand: kobe plugin action invoke you.example.greet [args…]
id = "greet" # local id, no dots; extra CLI args append to argv
title = "Say hello"
command = ["sh", "greet.sh"]
[[events]] # async observer fired by the daemon (catalog below)
on = "agent.turn-complete"
command = ["sh", "notify.sh"]
[[panes]] # a terminal surface in the task workspace
id = "board"
title = "Board"
placement = "split" # split (default: joins the focused chattab's
# split group beside the engine) | tab (own tab)
command = ["node", "$KOBE_PLUGIN_ROOT/board.js"] # cwd = the TASK WORKTREE
[[settings]] # rendered as an editor in Settings → Plugins
key = "YOU_EXAMPLE_MODE" # stored as KEY=value in your config .env
label = "Mode"
type = "enum" # string | number | boolean | enum
options = ["fast", "fancy"]
default = "fast"
[[file_handlers]] # claim Files-pane opens by filename pattern
pattern = "\\.(png|jpg)$" # JS regex, case-insensitive, vs the file name
action = "greet" # your action, invoked with the absolute pathcommand is always argv: never a shell, no expansion (panes expand only
$KOBE_PLUGIN_ROOT). Unknown event names are warnings (forward compat);
invalid types/patterns are install-time errors.
Event catalog
Declare [[events]] hooks; each fire runs your command with the envelope in
KOBE_PLUGIN_EVENT_JSON. Events are asynchronous observers. Your exit
code and output never block or change what happened. Support: C = Claude
Code, X = Codex (Kimi adapter pending).
| Event | Fires when | Detail highlights |
|---|---|---|
task.created / task.deleted | task appears/disappears in the index | task context |
task.landed | a task's branch merged back into its base repo | strategy, landedOn, commit |
task.archived | a task was archived (restores don't fire) | task context |
worktree.created | a task's worktree materialized | task context |
issue.changed | a daemon-tracker issue mutated (create/edit/status) | repo, op |
task.opened / project.opened | the user selects/enters a task / project row | |
file.will-open / file.opened / file.closed | Files-pane open, before/after; editor tab closed | path, via: plugin|editor|external |
tab.opened / tab.closed | a workspace tab appeared/went away (restores don't fire) | tabId, kind, title, vendor, purpose |
agent.running / agent.idle / agent.turn-complete / agent.permission-needed / agent.rate-limited / agent.error | activity-STATE transitions, deduped per task+tab | |
session.start / session.end | engine session lifecycle (C; X start only) | |
turn.prompt / turn.complete / turn.failed / turn.interrupted | one event per turn edge (C, X; interrupted: Kimi-shaped) | failure class on failed |
tool.pre / tool.post / tool.failed | every tool call (C, X; failed: C); installed into engine config only while some enabled plugin subscribes | tool.name, tool.id |
attention.permission / attention.question | the engine blocked on a human (C) | waiting |
context.pre-compact / context.post-compact | context compaction (C, X) | compact.trigger: manual|auto |
subagent.start / subagent.stop | nested agent lifecycle (C) | subagent.type/id |
Envelope (KOBE_PLUGIN_EVENT_JSON):
{
"event": "tool.post",
"taskId": "…", // when the event mapped to a task
"task": { "id", "title", "repo", "branch", "worktreePath", "vendor", "status" },
"vendor": "claude", // agent-layer events
"tabId": "…", "sessionId": "…",// when known
"detail": { /* per-event, see table */ },
"at": 1690000000000
}The principle: any observable product moment is a candidate event. The
catalog grows as subsystems expose their edges (PR status and task status
transitions are natural next ones). If your plugin needs a
moment that isn't fired yet, ask via kobe feedback or a GitHub issue; the
plumbing (ui.reportEvent → plugin sink) makes additions cheap.
Environment contract
Every plugin command gets, on top of the user's environment:
| Variable | Meaning |
|---|---|
KOBE_BIN_PATH | exec this to call back into kobe |
KOBE_SOCKET_PATH | daemon unix socket, for raw JSON requests |
KOBE_HOME_DIR | set when kobe runs against a non-default home (keep passing it through) |
KOBE_PLUGIN_ID, KOBE_PLUGIN_ROOT | who you are, where your files are |
KOBE_PLUGIN_CONFIG_DIR | user-editable config (.env etc.); survives reinstall |
KOBE_PLUGIN_STATE_DIR | your durable state; survives reinstall |
| events | KOBE_PLUGIN_EVENT, KOBE_PLUGIN_EVENT_JSON, KOBE_PLUGIN_TASK_ID, KOBE_PLUGIN_TASK_TITLE |
| startup | KOBE_PLUGIN_EVENT=startup |
| actions | KOBE_PLUGIN_ACTION_ID, KOBE_PLUGIN_INVOKE_CWD (where the user invoked, usually "the repo I mean") |
| panes | KOBE_PLUGIN_ENTRYPOINT_ID; cwd is the task worktree |
Never write durable state under KOBE_PLUGIN_ROOT. GitHub installs are
managed checkouts replaced on reinstall. Settings you declare in
[[settings]] arrive as plain vars in your config .env; source it
(. "$KOBE_PLUGIN_CONFIG_DIR/.env") or read it yourself.
Calling back into kobe
CLI (recommended, portable): exec $KOBE_BIN_PATH with any command.
The high-value verbs live under kobe api: machine-readable list via
kobe api schema, human list via kobe api help. Highlights:
"$KOBE_BIN_PATH" api add --repo <dir> --title T --prompt "…" # create task + start engine
"$KOBE_BIN_PATH" api dispatch --task-id ID --prompt "…" # text into a live session
"$KOBE_BIN_PATH" api list # all tasks (JSON)
"$KOBE_BIN_PATH" api notify --title "done" # toast in every attached UI
"$KOBE_BIN_PATH" api issue-create --repo <dir> --title "…" # daemon issue tracker
"$KOBE_BIN_PATH" api prompt --title "URL?" # host input dialog → {value}|{cancelled}
"$KOBE_BIN_PATH" api read-output --task-id ID # structured session reads
"$KOBE_BIN_PATH" plugin pane open you.example.board # open your own paneSocket (advanced): newline-delimited JSON frames on KOBE_SOCKET_PATH
({"type":"request","id":"1","name":"task.list","payload":{}}); request
names and payloads in packages/kobe-daemon/src/daemon/protocol.ts. Prefer
the CLI unless you need push channels.
Interaction surfaces
- ctrl+e picker: every enabled plugin's panes are listed by title; picking one opens it with your declared placement.
- User keybindings: users bind chords themselves in
~/.kobe/settings/keybindings.yaml:plugins: { ctrl+b: pane:you.example.board, f6: action:you.example.greet }. Ship the suggestion in your README; kobe ships no default plugin chords. - Files pane:
[[file_handlers]]claims opens by pattern. - Host input dialog:
kobe api prompt --title "…"(SDK:promptUser()) pops the TUI's standard input dialog and blocks for the answer:{value}on submit,{cancelled, reason}on esc/timeout. Use it instead of hand-rolling in-pane prompts. - Settings → Plugins: enable/disable, declared surfaces, last run,
and your
[[settings]]editors. - CLI:
kobe plugin action invoke,kobe plugin pane open,kobe plugin log.
Ground rules
- Hooks must be fast and silent. Event hooks run on real product
moments; do your slow work detached. Exit non-zero only for real failures;
output is capped at 8 KB per run in
log.jsonl. - Never block. Events are observers; there is no veto surface. Blocking tweaks (deny a tool call) belong in engine-native hooks the user installs directly.
- Trust model: plugins run as the user with their environment; installs preview every command and build step first, but nothing is sandboxed. Keep your repo auditable. That's what gets you installed.
- Reference implementations: the first-party plugins in Sma1lboy/kobe-plugins (notifications, GitHub/Linear task starters, lazygit pane, Chromium pane, the character-cell video player).