Lua extension tutorial

Webfetch and websearch with the Brave API

An agent that cannot see the web invents function names and guesses API details. Two tools fix that. Webfetch reads a page and returns markdown. Websearch queries the Brave Search API and returns ranked results. Both are thin wrappers around curl, about 80 lines together, and both come straight from my tools.lua.

1. Run commands with blitz.shell

Tools have no sockets, but they can run commands. blitz.shell runs one command and returns the output plus an ok flag. The force_local option matters in ssh mode. Routing sends tool calls to a remote host, and force_local = true keeps this one on your machine. Network calls belong where your keys are.

The key never appears in the config file, the Lua source, or a log. The curl commands read it from the $BRAVE_API_KEY environment variable.

2. The webfetch tool

I shell out to yomi read, a small html-to-markdown reader. Any CLI that turns a page into text works, for example curl -s URL | pandoc -f html -t gfm. For pages that need JavaScript, point the tool at a headless browser. The playwright MCP does that job on my machine.

~/.config/blitzdenk/tools.lua

blitz.register_tool({
	name = "webfetch",
	description = "Performs a web fetch and returns the content as markdown.",
	args = {
		url = { type = "string", description = "the url to fetch", required = true },
	},
	func = function(ctx, call)
		local url = call.arguments.url
		if type(url) ~= "string" or url == "" then
			error("url is required")
		end

		ctx:set_status("fetch " .. url)
		local content, ok = blitz.shell({
			cmd = "yomi read " .. url,
			force_local = true,
		})

		if not ok or content == nil or content == "" then
			error("fetch returned no output")
		end
		return { msg = content }
	end,
})

4. Clean the snippets

Brave wraps titles and descriptions in <b> markup and HTML entities. Fed in raw, they are noise in the context window. Strip tags, decode the common entities, collapse whitespace, and cap the snippet length.

local function clean(s)
	if type(s) ~= "string" then
		return ""
	end
	s = s:gsub("<[^>]*>", " ")
	s = s:gsub("&[a-zA-Z#0-9]+;", " ")
	s = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
	return s
end

-- in the result loop:
local snippet = clean(r.description)
if #snippet > 500 then
	snippet = snippet:sub(1, 500) .. "..."
end

5. Wire it up

Register the tool names in the tool set, and add one capability rule so the agent prefers searching over guessing. Capabilities probe for the named binary and appear in the agent's environment notes.

blitz.lua

local tools = require("tools")

blitz.set_agent_tools(blitz.AGENT_GENERAL, {
	blitz.tools.BASH,
	blitz.tools.READ,
	blitz.tools.WRITE,
	blitz.tools.EDIT,
	tools.webfetch,
	tools.websearch,
})

blitz.set_capabilities({
	{ binary = "curl", rule = "For library or API questions, search first with websearch. Never guess endpoints." },
})

Most research loops go the same way. Search with site: filters, fetch the promising page, read the markdown. One model call later the agent quotes real signatures instead of confident nonsense.