Lua extension tutorial
Herdr integration
Blitzdenk knows exactly what its main agent is doing: working, blocked on a permission ticket, or idle. herdr has a sidebar that wants to know the same thing. A small Lua module bridges the two. It shells out to the herdr CLI on every state change and pushes a few live tokens next to it.
1. What you get
herdr is a terminal runtime for coding agents. Every pane is a process herdr can observe, notify about, and wait on. Blitzdenk runs inside one of those panes. Once the module from this page is loaded, the sidebar row for that pane tracks the session:
- The row shows
idle,workingorblocked, with a label like "approve edit_file" or the current todo. - State changes raise herdr notifications, so a long run pings you while you are in another pane.
- Scripts can hold until the run finishes with
herdr agent wait.
This page builds a trimmed version of the module I run. The full script
lives in my dotfiles as config/blitzdenk/herdr.lua.
2. The contract
herdr injects four environment variables into every pane process; this
module needs three of them:
HERDR_ENV is
1 inside a herdr pane,
HERDR_PANE_ID identifies the pane,
and HERDR_BIN_PATH points at the
herdr binary. A pane process reports through the CLI. Three reports exist:
agent state via pane report-agent --state idle|working|blocked,
an optional resume command, and release via
pane release-agent. This module
sends state and release and skips the resume command.
Three rules keep reports accepted:
- Only report when
HERDR_ENV=1. Outside herdr the module does nothing. --sourcemust be stable and unique per reporter, and must never start withherdr:. That prefix is reserved.--seqmust grow monotonically. herdr drops any report older than the last one it saw.
canonical report command
"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
--source blitzdenk \
--agent blitzdenk \
--state working \
--message 'calling tools' \
--seq 42
--agent names the registration on
the pane, so the row in the sidebar is the one your script owns.
3. Detect the pane
Everything funnels through one guard. It returns the pane id only when the config actually runs inside herdr, so you can keep the module loaded all the time and it stays silent everywhere else.
herdr.lua
local function pane()
if os.getenv("HERDR_ENV") ~= "1" then
return nil
end
local id = os.getenv("HERDR_PANE_ID")
if id == nil or id == "" then
return nil
end
return id
end
4. Report state changes
The reporter is a string builder plus one
blitz.shell call. Two details
carry the whole design. Messages come from permission tickets and agent
questions, so they contain arbitrary text: shq
single-quotes everything, which is the only safe way through a shell. And
the sequence counter rides on os.time,
bumped by one whenever two reports land in the same second, so it never
repeats and herdr never drops a fresh report as stale.
herdr.lua
local SOURCE = "blitzdenk"
local function next_seq(key)
local last = tonumber(blitz.state.get(key)) or 0
local now = os.time()
if now <= last then
now = last + 1
end
blitz.state.set(key, now)
return now
end
local function shq(text)
return "'" .. tostring(text):gsub("'", "'\\''") .. "'"
end
local function run(cmd)
local _, ok = blitz.shell({ cmd = cmd, force_local = true, timeout = 2 })
return ok
end
local function state_report(pane_id, state, message)
local state_key = "herdr_state_" .. pane_id
local msg_key = "herdr_msg_" .. pane_id
if blitz.state.get(state_key) == state and (blitz.state.get(msg_key) or "") == message then
return
end
local cmd = "\"$HERDR_BIN_PATH\" pane report-agent \"$HERDR_PANE_ID\""
.. " --source " .. SOURCE
.. " --agent " .. SOURCE
.. " --state " .. state
.. " --message " .. shq(message)
.. " --seq " .. next_seq("herdr_seq")
if run(cmd) then
blitz.state.set(state_key, state)
blitz.state.set(msg_key, message)
end
end
The two cache keys turn the reporter idempotent. If the state and message
already match what herdr last accepted, the call is skipped. The cache only
updates when run succeeds, so a
failed report retries on the next push instead of being forgotten.
5. Wire the hooks
Seven hooks cover the session:
agent_started and
user_message_sent mean working,
agent_complete means idle,
agent_failed and
agent_cancelled drop back to idle,
permission_requested means blocked,
and session_reset releases the
agent from the pane. None of them report directly. Six hooks call
M.push(), which derives the state
from the live snapshot; session_reset
calls release() instead. One source
of truth, no drift between hook and row.
The derive step is where blocked gets its grace timer. A permission ticket that you approve within a second should never reach the sidebar, otherwise the row flaps on every auto-approved call. The timer starts on the first sight of the blocker and only reports blocked once it is a second old.
herdr.lua
local BLOCK_GRACE_SECONDS = 1
local function derive(pane_id, row)
local ok, tokens = pcall(tokens_for, row)
if not ok then
tokens = { ctx = tostring(math.floor(row.ctx or 0)) }
end
local blocker = main_blocker()
local since_key = "herdr_block_since_" .. pane_id
if blocker then
local since = blitz.state.get(since_key)
if since == nil then
blitz.state.set(since_key, os.time())
elseif os.time() - since >= BLOCK_GRACE_SECONDS then
return "blocked", blocker_label(blocker), blocker_message(blocker), tokens
end
elseif blitz.state.get(since_key) ~= nil then
blitz.state.set(since_key, nil)
end
if WORKING[row.state] then
return "working", WORK[row.state], "", tokens
end
return "idle", "", "", tokens
end
main_blocker walks
blitz.permissions.list_pending()
and picks the entry of the main agent. Its kind decides the label: a
question reads "question", a plan reads "review plan", a diff reads
"review patch", and a plain tool call reads
"approve tool". The hooks then
stay dumb:
herdr.lua
local function mine(ev)
return ev.id == blitz.get_main_agent()
end
blitz.hooks.agent_started(function(ev)
if mine(ev) then
M.push()
end
end)
blitz.hooks.user_message_sent(function() M.push() end)
blitz.hooks.agent_complete(function(ev) if mine(ev) then M.push() end end)
blitz.hooks.session_reset(function() release() end)
blitz.hooks.permission_requested(function()
M.push()
end)
blitz.hooks.session_reset(function()
release()
end)
Repeat the same one-liner for the remaining state hooks:
agent_failed and
agent_cancelled push exactly like
agent_complete. The
mine check keeps subagent
noise out: a background reviewer finishing must not flip your main row to
idle. release sends
pane release-agent with the next
sequence number and clears the cache, so the row disappears when you reset
the session.
6. Push live stats as metadata
State alone is a dot in a list. pane report-metadata
attaches named tokens to the row, and the herdr sidebar config renders them
wherever you put them. The module pushes seven: model, effort, context
percent, tokens per second, queued messages, the current todo, and cost.
herdr.lua
local TOKENS = { "model", "effort", "ctx", "tps", "queued", "todo", "cost" }
local STATUSES = { "working", "blocked", "idle" }
local function tokens_for(row)
local tokens = {
ctx = tostring(math.floor(row.ctx or 0)),
model = clean(row.model),
effort = clean(blitz.agent.get_effort(row.agent_id)),
todo = current_todo(row.agent_id),
}
if row.tps and row.tps >= 1 then
tokens.tps = string.format("%.1f", row.tps)
end
if row.queued and row.queued > 0 then
tokens.queued = tostring(row.queued)
end
local use = blitz.token_usage()
if use and (use.cost or 0) >= 0.01 then
tokens.cost = string.format("$%.2f", use.cost)
end
return tokens
end
local function meta_report(pane_id, state, label, tokens)
local args = {}
local pending = {}
for _, status in ipairs(STATUSES) do
local key = "herdr_label_" .. pane_id .. "_" .. status
local value = status == state and label or ""
if value ~= (blitz.state.get(key) or "") then
args[#args + 1] = "--state-label " .. status .. "=" .. shq(value)
pending[#pending + 1] = { key = key, value = value }
end
end
for _, name in ipairs(TOKENS) do
local key = "herdr_tok_" .. pane_id .. "_" .. name
local value = tokens[name] or ""
if value ~= (blitz.state.get(key) or "") then
if value == "" then
args[#args + 1] = "--clear-token " .. name
else
args[#args + 1] = "--token " .. name .. "=" .. shq(value)
end
pending[#pending + 1] = { key = key, value = value }
end
end
if #args == 0 then
return
end
local cmd = "\"$HERDR_BIN_PATH\" pane report-metadata \"$HERDR_PANE_ID\""
.. " --source " .. META_SOURCE
.. " --agent " .. SOURCE
.. " --applies-to-source " .. SOURCE
.. " --seq " .. next_seq("herdr_meta_seq")
.. " " .. table.concat(args, " ")
if run(cmd) then
for _, item in ipairs(pending) do
blitz.state.set(item.key, item.value)
end
end
end
Only changed tokens go on the command line, so a tick that changed nothing
sends nothing. Missing tokens are cleared instead of sent as empty strings,
which keeps "cost" off the row until it reaches one cent. The metadata
source is blitzdenk:meta, separate
from the state reporter, with its own sequence counter. It targets the state
row via --applies-to-source. A
failed report leaves the cache untouched, so the next push retries it, and
the five-minute full resync heals anything a lost report leaves behind,
which is what makes the cache-first write safe.
On the herdr side, the sidebar config decides where the tokens land. This is the agent row block I run, with the context percent turning orange and red as it fills:
~/.config/herdr/config.toml
[ui.sidebar.agents]
rows = [
["state_icon", "machine", "workspace", "tab"],
["agent", "state_text"],
["$model", "$effort", { token = "$ctx", rules = [
{ gt = 85, fg = "#f07178", bold = true },
{ gt = 70, fg = "#ffb454" },
{ contains = "", dim = true },
] }],
[{ token = "$todo", dim = true }],
]
7. Rate limit and resync
Context percent and tokens per second change constantly, so pushing only on
hooks would show stale numbers. The module therefore also exposes
tick, called from a draw panel
render on every frame. The tick itself throttles to one push per second,
and every five minutes it wipes the cache so the next push resyncs the full
row. The resync heals any drift from a report herdr never saw.
herdr.lua
local TICK_SECONDS = 1
local FULL_SYNC_SECONDS = 300
local last_tick = 0
local last_sync = 0
local function tick_now()
local now = os.time()
if now - last_tick < TICK_SECONDS then
return
end
last_tick = now
if now - last_sync >= FULL_SYNC_SECONDS then
last_sync = now
local pane_id = pane()
if pane_id then
clear_cache(pane_id)
end
end
push_now()
end
function M.tick()
local ok, err = pcall(tick_now)
if not ok then
blitz.log("herdr tick failed: " .. tostring(err))
end
end
One piece is still missing: the glue that ties the reports together.
M.push runs
push_now under
pcall,
push_now derives the state and
sends both reports, and
release drops the registration
and clears the cache:
herdr.lua
local M = {}
local function push_now()
local pane_id = pane()
if not pane_id then
return
end
local row = main_row()
if not row then
return
end
local state, label, message, tokens = derive(pane_id, row)
state_report(pane_id, state, message)
meta_report(pane_id, state, label, tokens)
end
function M.push()
local ok, err = pcall(push_now)
if not ok then
blitz.log("herdr push failed: " .. tostring(err))
end
end
local function release()
local pane_id = pane()
if not pane_id then
return
end
local cmd = "\"$HERDR_BIN_PATH\" pane release-agent \"$HERDR_PANE_ID\""
.. " --source " .. SOURCE
.. " --agent " .. SOURCE
.. " --seq " .. next_seq("herdr_seq")
if run(cmd) then
clear_cache(pane_id)
end
end
Both entry points wrap their body in pcall
and log failures. A broken herdr install must never take a draw callback or
a hook down with it.
8. Install and test
Save the module as ~/.config/blitzdenk/herdr.lua
and load it from your entry config:
blitz.lua
require("draw")
require("voice")
require("permissions")
require("commands")
require("herdr")
Then call herdr.tick() from any
draw panel render, the way my status panel does:
draw.lua
local herdr = require("herdr")
panel = blitz.draw.panel({
height = 3,
place = "below",
render = function(w, h, buf, frame)
herdr.tick()
end,
})
Start blitzdenk inside a herdr pane and send it a task. The pane's agent row should flip to working within a second. Check both sides from any pane:
shell
herdr agent list
herdr pane get "$HERDR_PANE_ID"
The first lists the registration with its state, the second shows the
reported state and metadata tokens on the pane. Trigger a permission prompt
and wait out the one-second grace to see
blocked with an approve label.
Then reset the session in blitzdenk: the row should disappear, because the
session_reset hook released the
agent. Stop the session, start it again, and send one more prompt:
herdr agent list shows blitzdenk
again, re-registered by the next push. My full version of the script lives
in my dotfiles under
config/blitzdenk/herdr.lua.