Extensions

Kadrell · extension API version 1

An extension is a small Lua program that runs next to Kadrell. It can react to session events, drive Kadrell with the same commands as the kadrell command line tool, show a tab in the right sidebar, and put one entry in the status bar. Every extension runs in its own helper process. If it crashes, hangs or floods Kadrell with messages, only that helper is stopped and the window stays usable.

Quick start

Extensions live in ~/.config/kadrell/extensions/<name>/. Create the folder hello there with these two files. The folder name has to match the name in the manifest.

~/.config/kadrell/extensions/hello/kadrell.json

{
  "name": "hello",
  "version": "0.1.0",
  "description": "Counts sessions in the right sidebar and the status bar",
  "apiVersion": 1
}

~/.config/kadrell/extensions/hello/init.lua

local function count()
  local n = 0
  for _, group in ipairs(kadrell.sessions().groups) do n = n + #group.sessions end
  return n
end

local function render()
  local text = count() .. " sessions"
  kadrell.panel.set{
    title = "Hello",
    children = {
      { type = "text", text = text, color = "muted" },
      { type = "button", label = "Refresh", action = "refresh" },
    },
  }
  kadrell.status.set{ text = text, color = "ok", action = "refresh" }
end

kadrell.on("app.ready", render)
kadrell.on("session.new", render)
kadrell.on("session.remove", render)
kadrell.on("ui.action", render)

Press F4 to open the Extensions dialog and switch hello on. A tab called "Hello" appears in the right sidebar and the status bar shows the session count. Clicking the status entry or pressing ⏎ on the Refresh button (⌘3 moves the keyboard to the sidebar) sends the action refresh back to the extension, which draws again. Edit init.lua and save: Kadrell reloads the extension on its own.

Kadrell only loads an extension whose folder, kadrell.json and every .lua file in it, subfolders included, belong to you and are not writable by group or others. That is the same check shell hooks go through. The dialog names the file that fails it.

Manifest reference

kadrell.json sits in the extension folder. Only name and apiVersion are required.

FieldMeaning
nameMust equal the folder name, otherwise the extension does not load.
apiVersionThe API version the extension was written for. Currently 1. A higher number than Kadrell supports does not load and the dialog says it needs a newer Kadrell. Breaking API changes will raise the number. New functions and events are added without raising it.
versionFree text, shown in the dialog.
descriptionOne line, shown in the dialog.
permissionsList of strings the extension says it needs, for example ["exec", "http:*.atlassian.net"]. The dialog shows them. They are not enforced yet. An extension can run any program and open any HTTPS connection with your user's rights, whatever it lists here.
settingsList of settings the dialog turns into a form, see below.

Settings

Each entry has key, type, label and an optional default.

"settings": [
  {"key": "jiraUrl",  "type": "string", "label": "Jira URL"},
  {"key": "token",    "type": "secret", "label": "API token"},
  {"key": "onlyMine", "type": "bool",   "label": "Only my tickets", "default": true}
]
TypeForm control and storage
stringText field. Stored in the preferences of the current Kadrell profile.
secretPassword field. Stored in the macOS keychain, one entry per profile, extension and key. The dialog only shows whether a value is set, never the value.
boolSwitch. Stored in the profile preferences.

The values arrive in kadrell.config.<key> as Lua strings and booleans (a secret is a string). A setting without a stored value takes its default, and is nil if there is none. A text field saves on ⏎ or when you move to another field, and saving restarts the extension so it sees the new value.

Why an extension does not load

The dialog shows one of these instead of a state, and the switch is disabled.

Lifecycle and limits

For every enabled extension Kadrell starts its own binary as a helper (Kadrell ext-host <folder>). It gets the environment a Claude session gets, taken from your login shell, so git, ddev and the like are on PATH. Kadrell talks to the helper over stdin and stdout, one JSON line per message. It never waits for an extension: writes to the helper do not block, and an extension that stops reading is treated as hung.

The Lua code in the helper runs on one thread. Handlers take turns, and a handler that waits for exec or http lets the others run in the meantime.

SituationWhat happens
StartKadrell sends hello, the helper runs init.lua from top to bottom and then reports ready. If that takes longer than 3 seconds the helper is killed ("Does not start"). Keep slow work out of the top level of init.lua and put it in a handler. An error in init.lua is logged with a traceback and ends the helper.
Switching offKadrell sends shutdown, then SIGTERM after 1 second, then SIGKILL after another second. The panel and the status entry disappear at once.
CrashAny exit that Kadrell did not ask for, including a helper that ends itself with status 0. Panel and status entry disappear, the last error is shown in the dialog, and the extension restarts after 1 s, then 5 s, then 30 s for further consecutive crashes. A run of 2 minutes or more after ready resets that count. 3 crashes within 2 minutes and the extension stays off ("3 crashes in 2 min, stays off") until you switch it off and on again, press R in the dialog, or save one of its files. So the 30 s step only comes when the crashes are spread out; crashes in quick succession hit the 3-in-2-minutes rule first.
HangKadrell sends a ping every 5 seconds. If the previous ping is still unanswered when the next one is due, the helper is killed ("Not responding"). A Lua loop that never returns to the event loop gets killed this way.
FloodThe helper is killed ("Too many messages") when it sends more than 50 messages per second, a single line longer than 1 MB, or when more than 1 MB of Kadrell's messages sit unread in front of it. Panel and status updates are combined per handler run: however often a handler calls panel.set or status.set, only the last panel and the last status entry go out, once, when the handler returns or starts to wait. A timer that fires more than 50 times a second and sets the panel each time still counts. kadrell.run and kadrell.sessions do not count, because each call waits for its answer. Log lines and stderr do not kill either: they are cut off at 50 lines per second and the rest is replaced by one line "N lines skipped". All other messages count toward the 50 per second. A panel with more than 2000 nodes (every element of a children list counts, and so does every action) stops the helper at once. All of these count as crashes.
File changeKadrell watches the extensions folder. When a .lua file or the kadrell.json of an enabled extension changes, it restarts after a 300 ms pause. This is not a crash and it also revives an extension that had given up. Other files in the folder do not trigger a reload, and neither does storage, which lives elsewhere.
Setting changedThe extension restarts with the new kadrell.config.
Folder deleted or renamedThe helper stops and the entry leaves the dialog. A new folder shows up as soon as it exists.
Kadrell quitsExtensions are stopped like switching off. A helper whose stdin closes exits by itself, even when its Lua code is stuck in a loop.

Other limits

API reference

Extensions are written in Lua 5.5. The standard library is available except os.execute, io.popen, package.loadlib and the loaders for C modules. Programs and network access go through kadrell.exec and kadrell.http. require loads other .lua files from the extension folder.

Handlers and how waiting works

Every event handler and every timer callback runs in its own coroutine. kadrell.exec, kadrell.http, kadrell.run and kadrell.sessions suspend that coroutine until the result is there, so the code reads top to bottom. An error inside a handler is logged with a traceback and the extension keeps running. Things to watch for:

print writes to the log. So does anything sent to io.write or io.stdout: those end up in the log as stderr lines and never touch the protocol channel.

Events

kadrell.on(name, function(data) ... end)

Several handlers for the same event all run, in the order they were registered.

EventData
app.readyAn empty table. Sent once Kadrell has started. An extension that starts later, because you switched it on or it reloaded, gets it right after loading. Read the current state with kadrell.sessions().
session.newdata.session
session.removedata.session
session.focusdata.session, the session that got the focus. An extension that starts later also gets it once, right after app.ready, with the session that got the focus last.
session.statusdata.session and data.state. Only sent when the state changes, not on every new title or branch.
ui.actiondata.id, the id or action of the item, button or status entry that was used.

data.session is a table with key (the session key that kadrell ls prints), title, cwd, branch (nil outside a git checkout), sessionId, state and group (the group name, nil if there is none). state is one of running, waiting, idle or error.

Controlling Kadrell

local r = kadrell.run("select", "-t", key)   -- r.status, r.stdout, r.stderr
local ls = kadrell.sessions()

kadrell.run(...) runs a Kadrell command exactly like kadrell <args> on the command line (kadrell help lists them) and returns {status, stdout, stderr}. Arguments are converted to strings. Status 0 is success, 1 is an error with the message in stderr. The call counts as coming from outside, so the setting that limits sessions to their own group does not apply. An extension can type into any session with send, close sessions with kill, and so on.

kadrell.sessions() runs ls --json and returns the decoded result:

{ layout = "grid", focused = "<key>" or nil,
  groups = {
    { id, name, color, cwd, favorite,
      sessions = {
        { key, sessionId, title, cwd, branch, status, running, shown, focused },
      } },
  } }

Here the field is called status and holds running, waiting, idle or error for a live session, stopped or ended for one without a running Claude process. In event data the same value is called state.

Programs and HTTP

local r = kadrell.exec({"git", "status", "--short"}, { cwd = dir, timeout = 10 })
-- r.status, r.stdout, r.stderr

local r = kadrell.http{
  method = "POST",                 -- default "GET"
  url = "https://example.com/api",
  headers = { ["Authorization"] = "Bearer " .. token },
  body = kadrell.json.encode({ a = 1 }),
  timeout = 10,                    -- default 30
}
-- r.status, r.headers, r.body

For kadrell.exec, the first element of argv is the program, the rest are its arguments. There is no shell; use {"sh", "-c", "..."} if you want one. A name without a slash is looked up on PATH, a path with a slash is used as it is. Elements are converted to strings. cwd and timeout (seconds, default 30) are optional. If the program cannot be started, status is 127 and stderr holds the system's message. When the timeout runs out the process is killed with SIGKILL and status is 9. Nothing else marks it as a timeout.

kadrell.http returns {status, headers, body}, and any HTTP status, including 404 and 500, counts as a normal reply. Network failures come back as status = 0, an empty body and an error field with the message. Three details:

Timers

local h = kadrell.every(60, function() ... end)
kadrell.after(5, function() h:cancel() end)

kadrell.after(seconds, fn) runs fn once, kadrell.every(seconds, fn) repeatedly. Both return a handle with handle:cancel(). The callbacks are handlers, so they may call exec and the other waiting functions. every plans the next run after the current one has finished, so runs never overlap. Timers do not survive a restart; set them up again in init.lua.

Settings, storage and JSON

kadrell.config.jiraUrl
kadrell.storage.set("seen", { "PROJ-1" })
local seen = kadrell.storage.get("seen")
kadrell.json.encode(v)   kadrell.json.decode(s)

kadrell.storage keeps values in a JSON file, storage.json in ~/Library/Application Support/de.malura.kadrell/extensions/<name>/. Other Kadrell profiles have their own directory. The values survive reloads and restarts. Every call reads or rewrites the whole file, so keep it small. An unreadable file counts as empty and is overwritten by the next set.

kadrell.json is rxi's json.lua. An empty Lua table is encoded as a JSON array ([]), because Lua cannot tell the two apart. A table that mixes a list part with string keys, or a list with gaps, raises an error. That is the reason the panel root keeps its nodes under children.

Logging

kadrell.log("text")    kadrell.warn("text")

Both write a line to the extension's log. Warnings and handler errors are prefixed with [warn] and [error].

Right sidebar panel

kadrell.panel.set{
  title = "Jira",
  children = {
    { type = "section", title = "In progress", children = {
        { type = "item", text = "PROJ-123 Login broken", detail = "High", color = "warn",
          actions = { { id = "open:PROJ-123",   label = "Open in browser" },
                      { id = "claude:PROJ-123", label = "Start session" } } },
    } },
    { type = "text", text = "Updated 14:02", color = "muted" },
    { type = "button", label = "Reload", action = "refresh" },
  },
}
kadrell.panel.clear()

panel.set replaces the whole tree, there is no diffing. title is the tab label and defaults to the extension name. Each extension with a panel gets one tab, and the sidebar is only there while at least one enabled extension has one. Kadrell draws the nodes in its own style, scaled with the UI size you chose, so an extension never touches the interface directly. Windows opened with ⌘⇧T show the same panels.

NodeFields
sectiontitle, children, collapsed (start closed). A section you open or close by hand keeps that state across later panel.set calls.
itemtext (required), detail, color (draws a dot), actions, a list of {id, label}. Selecting the item runs its first action, the others sit in a menu.
texttext (required), color (default muted). Not selectable.
buttonlabel and action, both required.

Colors are given as theme names: accent, muted, ok, warn and err. A node with an unknown type or without a required field is skipped. An unknown color is ignored and the node is drawn without it. Each case writes a warning to the log, at most 20 per panel update plus one line with the rest. Every action id arrives as a ui.action event.

Status bar entry

kadrell.status.set{ text = "ddev 3 up", color = "ok", action = "ddev:list" }
kadrell.status.clear()

Each extension has at most one entry in the status bar. text is required and cut to 40 characters, color is a theme name, and action is optional. With an action, a click sends ui.action with that id. Without one the entry is display only. Entries that do not fit into the bar are left out. The entry disappears when the extension is switched off or stops.

Keyboard

Right sidebar

KeyEffect
⌘3Move the keyboard to the right sidebar. Without any panel you only hear a beep.
⌘⌥BShow or hide the right sidebar.
↑ ↓Select an entry. Sections, items and buttons can be selected, text lines cannot.
← →Switch tab.
⏎ or SpaceOpen or close a section, run the first action of an item, press a button.
⌥⏎Menu with all actions of the item. A right-click does the same.
EscGive the keyboard back to the terminal tile.

The first two can be rebound in the settings (⌘,).

Extensions dialog

Open it with F4, with "Extensions …" in the Kadrell menu, or with the "Extensions" command in the palette. It lists every folder it finds, with state, version, description and the permissions the manifest asks for. For the selected extension it offers Reload, Open folder, the log and the settings form. "Open extensions folder" at the top creates the folder if it does not exist yet.

KeyEffect
↑ ↓Select an extension.
SpaceSwitch the selected extension on or off.
RReload it. Also restarts one that gave up.
LShow or hide the log.
Esc or F4Close the dialog. Unsaved text in a settings field is dropped.

Publishing

Publish an extension as a public GitHub repository and give it the topic kadrell-extension. Everything under that topic is then listed at github.com/topics/kadrell-extension. The repository root is the extension folder:

hello/
  kadrell.json
  init.lua
  other-module.lua     (optional, loaded with require)
  README.md
  LICENSE

Name the repository like the name in the manifest, because the folder Kadrell reads has to carry that name. To install one, clone it into the extensions folder and switch it on:

git clone https://github.com/you/hello ~/.config/kadrell/extensions/hello

Then press F4 and switch it on. It appears in the dialog as soon as the folder exists. To update, run git pull in that folder: the changed files make Kadrell reload the extension. Kadrell has no installer or update check for extensions yet.

Read the code before you switch an extension on. An extension can run programs, send requests and control all your sessions with your user's rights. The permissions field is shown but not enforced.