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.