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,
})
3. The Brave search tool
Get a key at
api-dashboard.search.brave.com
and export it in your shell profile. The free tier allows one request per
second, plenty for an agent. Percent-encode the query, cap
max_results, and fail loud on a
missing key before curl runs.
tools.lua
blitz.register_tool({
name = "websearch",
description = "Search the web with Brave. Supports Brave query operators such as site: and filetype:.",
args = {
searchQuery = { type = "string", description = "the search query", required = true },
max_results = { type = "number", description = "maximum results to return (default 10, max 20)" },
},
func = function(ctx, call)
local query = call.arguments.searchQuery
if type(query) ~= "string" or query == "" then
error("searchQuery is required")
end
local api_key = os.getenv("BRAVE_API_KEY")
if type(api_key) ~= "string" or api_key == "" then
error("BRAVE_API_KEY is not set")
end
local max = tonumber(call.arguments.max_results) or 10
max = math.min(math.max(max, 1), 20)
-- RFC 3986 percent-encode
local function urlencode(s)
local rep = function(c)
return string.format("%%%02X", string.byte(c))
end
return (s:gsub("([^%w%-_%.~])", rep))
end
ctx:set_status("search " .. query)
local url = "https://api.search.brave.com/res/v1/web/search?q="
.. urlencode(query)
.. "&count=" .. max
.. "&result_filter=web&text_decorations=false"
local body, ok = blitz.shell({
cmd = "curl -sS --max-time 15"
.. " -H 'Accept: application/json'"
.. " -H \"X-Subscription-Token: $BRAVE_API_KEY\" '"
.. url .. "'",
force_local = true,
})
if not ok or type(body) ~= "string" or body == "" then
error("brave request failed")
end
local val, ok = blitz.json.decode(body)
if ok == false then
error("failed to parse brave json response")
end
local results = type(val) == "table" and type(val.web) == "table"
and val.web.results or nil
if type(results) ~= "table" or #results == 0 then
return { msg = "No results for: " .. query }
end
local lines = { "Search results for: " .. query, "" }
for i = 1, math.min(max, #results) do
local r = results[i]
lines[#lines + 1] = string.format("[%d] %s", i, clean(r.title))
lines[#lines + 1] = " " .. tostring(r.url or "")
lines[#lines + 1] = " " .. clean(r.description)
lines[#lines + 1] = ""
end
return { msg = table.concat(lines, "\n") }
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.