Guides
~1.6k tokens

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

  1. Write the code in ~/.config/maki/lua/<name>.lua. The maki global 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,
})
  1. Load it from ~/.config/maki/init.lua, creating that file if missing:
require("hello")
  1. 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
  1. 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; check err before using value.
  • 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.