Lua extension tutorial

How to create todo tools

An agent that keeps its plan in the conversation loses the plan when the context fills up. A todo list fixes that. The list lives in blitz.state, four tools edit it, and an inject hook puts the open counts in front of the model on every step. This is the todo.lua from my own config, cut to the parts you need.

1. One list per agent

Tool calls run in their own Lua VM, and locals die between calls. Store the list in blitz.state. It is a key-value store that config, tools, and hooks all see. Key it by ctx.agent_id and every agent gets its own list, main agent and sub-agents alike.

~/.config/blitzdenk/todo.lua

-- pending → in_progress → done
local MARKS = {
	pending = "[ ]",
	in_progress = "[~]",
	done = "[x]",
}

local function key(agent_id)
	return "todo_" .. agent_id
end

local function load(agent_id)
	return blitz.state.get(key(agent_id)) or {}
end

local function save(agent_id, list)
	blitz.state.set(key(agent_id), list)
end

local function next_id(agent_id)
	local n = blitz.state.get(key(agent_id) .. "_next") or 1
	blitz.state.set(key(agent_id) .. "_next", n + 1)
	return n
end

local function find(list, id)
	for _, t in ipairs(list) do
		if t.id == id then
			return t
		end
	end
end

2. The four tools

Each tool validates its input, mutates the list, and reports back. Two details matter. error("...") fails the call, and only the message reaches the chat, so a wrong id fails loud instead of confusing the model. ctx:set_status(...) writes the status line in the TUI, so you watch progress without reading tool output.

todo.lua

blitz.register_tool({
	name = "todo_add",
	description = "Add a task to the TODO list. The new task starts in state pending.",
	args = {
		text = { type = "string", description = "the task description", required = true },
	},
	func = function(ctx, call)
		local text = call.arguments.text
		if type(text) ~= "string" or text == "" then
			error("text is required")
		end
		local list = load(ctx.agent_id)
		local id = tostring(next_id(ctx.agent_id))
		list[#list + 1] = { id = id, text = text, state = "pending" }
		save(ctx.agent_id, list)
		ctx:set_status("[ ] new todo `" .. text .. "`")
		return { msg = "added #" .. id .. " pending: " .. text }
	end,
})

blitz.register_tool({
	name = "todo_start",
	description = "Start work on a pending TODO. It moves to state in_progress.",
	args = {
		id = { type = "string", description = "the TODO id", required = true },
	},
	func = function(ctx, call)
		local id = tostring(call.arguments.id)
		local list = load(ctx.agent_id)
		local todo = find(list, id) or error("todo #" .. id .. " not found")
		todo.state = "in_progress"
		save(ctx.agent_id, list)
		ctx:set_status("[~] started `" .. todo.text .. "`")
		return { msg = "in progress #" .. id .. ": " .. todo.text }
	end,
})

blitz.register_tool({
	name = "todo_done",
	description = "Finish a TODO by id. It moves to state done.",
	args = {
		id = { type = "string", description = "the TODO id", required = true },
	},
	func = function(ctx, call)
		local id = tostring(call.arguments.id)
		local list = load(ctx.agent_id)
		local todo = find(list, id) or error("todo #" .. id .. " not found")
		todo.state = "done"
		save(ctx.agent_id, list)
		ctx:set_status("[x] finished `" .. todo.text .. "`")
		return { msg = "finished #" .. id .. ": " .. todo.text }
	end,
})

blitz.register_tool({
	name = "todo_list",
	description = "List the open TODOs. Started tasks come first, done tasks are not shown.",
	func = function(ctx)
		local in_progress, pending = {}, {}
		for _, t in ipairs(load(ctx.agent_id)) do
			if t.state == "in_progress" then
				in_progress[#in_progress + 1] = "[~] #" .. t.id .. " " .. t.text
			elseif t.state == "pending" then
				pending[#pending + 1] = "[ ] #" .. t.id .. " " .. t.text
			end
		end
		local lines = { table.concat(in_progress, "\n"), table.concat(pending, "\n") }
		local out = table.concat(lines, "\n")
		if out == "" then
			return { msg = "no open todos" }
		end
		return { msg = out }
	end,
})

Write args as a map keyed by argument name. A list of tables publishes an empty schema, and the model then guesses argument names. Blitzdenk does not validate against the schema before the call, so check critical inputs in the function, as above.

3. Inject the list into every step

The tools alone are not enough. On a long task the early entries scroll out of view and the model forgets them. An inject hook appends a line to the agent's system reminder on every step, so the open counts return each turn.

todo.lua

blitz.hooks.inject({
	digest = true,
	func = function(agent_id)
		local open, running = 0, 0
		for _, t in ipairs(load(agent_id)) do
			if t.state == "in_progress" then
				running = running + 1
			elseif t.state == "pending" then
				open = open + 1
			end
		end
		return string.format("[TODOS] in_progress=%d open=%d", running, open)
	end,
})

digest = true suppresses the text while it matches the last text this hook returned. The line costs tokens only when a count changes. Without it, every step repeats the same reminder.

4. Hand the tools to the agent

Lua files hot reload. Save todo.lua in ~/.config/blitzdenk/, require it from blitz.lua, list the tool names in the agent's tool set, and the running session picks all of it up within a second. No restart.

~/.config/blitzdenk/blitz.lua

blitz.set_agent_tools(blitz.AGENT_GENERAL, {
	blitz.tools.BASH,
	blitz.tools.READ,
	blitz.tools.WRITE,
	blitz.tools.EDIT,
	"todo_add",
	"todo_start",
	"todo_done",
	"todo_list",
})

This list also gives you something to draw. The sidebar tutorial renders exactly this list in the TUI.