Extensions
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.
| Field | Meaning |
|---|---|
name | Must equal the folder name, otherwise the extension does not load. |
apiVersion | The 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. |
version | Free text, shown in the dialog. |
description | One line, shown in the dialog. |
permissions | List 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. |
settings | List 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}
]
| Type | Form control and storage |
|---|---|
string | Text field. Stored in the preferences of the current Kadrell profile. |
secret | Password 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. |
bool | Switch. 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.
kadrell.json is missing or brokenName in the manifest ("x") does not match the folder nameNeeds a newer Kadrell (API 2, supported: 1)init.lua is missing<file> is not owned by you or is writable by others
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.
| Situation | What happens |
|---|---|
| Start | Kadrell 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 off | Kadrell sends shutdown, then SIGTERM after 1 second, then SIGKILL after another second. The panel and the status entry disappear at once. |
| Crash | Any 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. |
| Hang | Kadrell 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. |
| Flood | The 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 change | Kadrell 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 changed | The extension restarts with the new kadrell.config. |
| Folder deleted or renamed | The helper stops and the entry leaves the dialog. A new folder shows up as soon as it exists. |
| Kadrell quits | Extensions 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
- Lua memory is capped at 64 MB per extension. Going over raises a Lua error.
- Strings that Kadrell displays or logs are cut to 500 characters: panel texts, action labels, log lines. The status entry text is cut to 40 characters.
- Kadrell keeps the last 200 log lines of each extension in memory. Open them with L in the dialog. They hold
kadrell.logandkadrell.warnoutput,print, handler errors and stderr, and they are gone after Kadrell quits. execandhttptime out after 30 seconds unless you pass another value.
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:
- The waiting functions only work inside handlers, meaning functions passed to
kadrell.on,kadrell.everyandkadrell.after. At the top level ofinit.luathey raise an error such askadrell.exec: only allowed inside handlers (kadrell.on, every, after). To do something right after loading, usekadrell.after(0, fn)or theapp.readyevent. - Do not call
coroutine.yieldyourself inside a handler. Nothing resumes it, so that handler stays suspended for good. - A long computation without a wait blocks every other handler and the ping answer. See "Hang" above.
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.
| Event | Data |
|---|---|
app.ready | An 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.new | data.session |
session.remove | data.session |
session.focus | data.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.status | data.session and data.state. Only sent when the state changes, not on every new title or branch. |
ui.action | data.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:
- Use
https://. macOS App Transport Security blocks plainhttp://to host names, and the call fails with status 0 and an error. Plain HTTP to an IP address, such as127.0.0.1, works. timeoutis an idle timeout. It limits the wait between pieces of data, not the total duration of the request.- The body is decoded as UTF-8. Binary responses come back damaged.
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.
| Node | Fields |
|---|---|
section | title, children, collapsed (start closed). A section you open or close by hand keeps that state across later panel.set calls. |
item | text (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. |
text | text (required), color (default muted). Not selectable. |
button | label 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
| Key | Effect |
|---|---|
| ⌘3 | Move the keyboard to the right sidebar. Without any panel you only hear a beep. |
| ⌘⌥B | Show or hide the right sidebar. |
| ↑ ↓ | Select an entry. Sections, items and buttons can be selected, text lines cannot. |
| ← → | Switch tab. |
| ⏎ or Space | Open 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. |
| Esc | Give 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.
| Key | Effect |
|---|---|
| ↑ ↓ | Select an extension. |
| Space | Switch the selected extension on or off. |
| R | Reload it. Also restarts one that gave up. |
| L | Show or hide the log. |
| Esc or F4 | Close 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.