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, working or blocked, 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.
  • --source must be stable and unique per reporter, and must never start with herdr:. That prefix is reserved.
  • --seq must 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.