Writing maki plugins
Maki plugins are plain Lua files (Luau) that run inside maki. A plugin can
register tools the LLM calls, slash commands, keymaps, prompt hints, and
custom UI. Everything lives under the global maki table. The full API
reference is at the end of this document.
Where plugin code goes
Plugins live in the maki config dir. There are two of them, same layout:
~/.config/maki/- global, every project (if~/.maki/exists, maki reads that one instead)<project>/.maki/- this project only
init.lua the only file maki runs; require()s plugins, calls maki.setup()
lua/<name>.lua plugin modules, loaded by require("<name>")
plugin.toml permission grants for every Lua file in the dir
Nothing under lua/ loads on its own. A module name is its path under lua/
without the extension: lua/browser.lua is require("browser"),
lua/acme/tools.lua is require("acme.tools"). require is sandboxed to
that directory, you cannot reach files outside it.
Creating a plugin
- Write the code in
~/.config/maki/lua/<name>.lua. Themakiglobal is already there, nothing to import. For a project-only plugin use<project>/.maki/here and in every step below.
maki.api.register_tool({
name = "hello",
description = "Say hello to a name.",
parameters = { type = "object", properties = { name = { type = "string" } }, required = { "name" } },
handler = function(args)
return { llm_output = "hello " .. args.name }
end,
})
- Load it from
~/.config/maki/init.lua, creating that file if missing:
require("hello")
- Grant the permissions it needs in
~/.config/maki/plugin.toml, creating that file if missing. Without the file every gated call is denied.
[permissions]
fs_read = true
run = true
- Run
/reload, then read the log as described below, to see that it loaded and what it printed.
Leave maki.api.register_options to bundled plugins: maki rejects a
plugins.<name> table for a plugin it does not ship, and startup fails. Keep
settings in a local table, or export a setup(opts) function init.lua calls.
Permissions and plugin.toml
Sensitive APIs are gated per plugin file, and a plugin without a
plugin.toml next to it gets nothing. The gates and the file format are
in the reference.
Development loop
/reload rebuilds plugins and config in place, no restart needed. Until it
runs, an edited plugin is still the old one.
To debug, add maki.log.info|warn|error(...) calls. They write to maki.log
in the dir maki.env.logs_dir() returns (Linux: ~/.local/logs/maki/). When
a backtrace comes out useless, start maki with --no-jit: plugins then run on
the interpreter, with full debug info.
Conventions
- Fallible runtime calls return a
(value, err)pair; checkerrbefore usingvalue. - Tool handlers report failures with
{ llm_output = "error: ...", is_error = true }, not by raising. - The model picks tools by reading
description, so state precisely what the tool does and when to use it. - Reusable helpers ship with maki; see "Shared helper modules" in the API reference.
A complete real example
The bundled glob tool, verbatim: schema, header and restore hooks, error
handling, LLM output truncation, collapsible UI view. It is a bundled plugin,
so it opens with register_options, which your own plugin skips:
local truncate = require("maki.truncate")
local ToolView = require("maki.tool_view")
local shorten_path = require("maki.shorten_path")
local output_limits = require("maki.output_limits")
local NO_FILES_FOUND = "No files found"
local opts = maki.api.register_options(output_limits.extend({
search_result_limit = { default = 100, min = 10, desc = "Max files returned per search." },
}))
local function glob_view_opts(ctx)
local tol = ctx:tool_output_lines()
return { max_lines = (tol and tol.other) or 3, keep = "head" }
end
maki.api.register_tool({
name = "glob",
kind = "search",
description = [[Find files by glob pattern.
- Respects .gitignore.
- Returns absolute paths sorted by modification time (newest first).
- Prefer speculative parallel searches over sequential rounds of glob+grep.]],
schema = {
type = "object",
properties = {
pattern = { type = "string", description = "Glob pattern (e.g. **/*.rs, src/**/*.ts)", required = true },
path = { type = "string", description = "Directory to search in (default: cwd)" },
},
},
header = function(input)
local buf = maki.ui.buf()
local spans = { { shorten_path(input.pattern or ""), "tool" } }
if input.path then
spans[#spans + 1] = { " in ", "dim" }
spans[#spans + 1] = { shorten_path(input.path), "path" }
end
buf:line(spans)
return buf
end,
restore = function(_input, output, _is_error, ctx)
return ToolView.restore(output, glob_view_opts(ctx))
end,
handler = function(input, ctx)
local pattern = input.pattern
if not pattern then
return { llm_output = "error: pattern is required", is_error = true }
end
local limit = opts.search_result_limit
local max_lines, max_bytes = output_limits.resolve(opts, ctx)
local files, err = maki.fs.glob(pattern, {
path = input.path,
gitignore = true,
sort = "mtime",
limit = limit,
})
if not files then
return { llm_output = "error: " .. err, is_error = true }
end
if #files == 0 then
return { llm_output = NO_FILES_FOUND }
end
local lines = {}
for i, f in ipairs(files) do
lines[i] = shorten_path(f)
end
local text = table.concat(lines, "\n")
local llm_output = truncate(text, max_lines, max_bytes)
local buf = maki.ui.buf()
local view = ToolView.new(buf, glob_view_opts(ctx))
for _, line in ipairs(lines) do
view:append(line)
end
view:finish()
buf:on("click", function()
view:toggle()
end)
return {
llm_output = llm_output,
body = buf,
}
end,
})Full API reference
Every module, function, and method is in the Lua API reference.
The agent gets the same document on disk through the builtin
maki-plugin-dev skill, so asking it to write a plugin for you works
without pasting any of this.