Reference
~2.3k tokens

Hooks

Maki has two ways for Lua to react to what the agent does.

You want toUse
Know that something happenedAutocmds
Change what happens, or stop itSlots

Autocmds are notifications. Many plugins can listen to one event, they run in no particular order, and what they return is ignored. Slots are a chain: each layer gets the value, decides, and passes it down by calling prev. The last layer registered runs first.

Both come from Neovim. An autocmd matches nvim_create_autocmd, and a slot plays the role vim.ui.select plays there, with the wrapping made explicit so two plugins can layer the same point without capturing each other's function.

Tool slots

Every tool has two slots that maki fires itself, so the tool's author does not have to add a hook point. Both fire from the single function every tool call passes through, so builtins, MCP tools, and ACP client tools behave alike:

SlotFiresGets
tool.<name>.inputbefore the input is parsed or checked against your permission rulesthe call the model wrote
tool.<name>.outputon the result of a call that ran, including a failure or a permission refusal{ text, is_error }

A call an input layer stopped never reaches the output slot: the reason came from a layer, so there is nothing left to filter. A name that resolves to no tool fires neither slot.

Layers take function(prev, value, ctx) and answer in one of three ways:

  • return a table: it replaces the value for the rest of the call
  • return nothing: the value is left alone
  • return nil, reason: the call is stopped and the model reads reason

Stopping means something different per stage. On input the tool never runs and reason becomes the tool result, marked as an error. On output the work is already done, so reason only replaces the text the model reads.

ctx carries tool, tool_id, session_id, and origin. Origin is "model" for a call the model made and "nested" for one made on its behalf by batch, code_execution, or a plugin calling maki.agent.call_tool. Nested calls have no id of their own, so their tool_id is empty. A subagent runs its own model, so its calls arrive as "model" under the subagent's session_id.

Rewriting a command

The model reaches for grep -r even when rg is installed. Denying costs a model round trip every time it happens. A rewrite fixes the command in place:

maki.api.set_slot("tool.bash.input", function(prev, input, ctx)
  local rewritten = input.command:gsub("^grep %-r ", "rg ")
  if rewritten == input.command then
    return
  end
  input.command = rewritten
  return prev(input, ctx)
end)

rg and grep -r do not search the same files: rg skips what .gitignore lists, hidden files, and binaries. That is why maki does not do this for you.

Blocking a command

When there is no good rewrite, stop the call and say why. The reason reaches the model as the tool result:

maki.api.set_slot("tool.bash.input", function(prev, input, ctx)
  if input.command:find("git push %-%-force") then
    return nil, "Force pushing is not allowed here. Open a PR instead."
  end
  return prev(input, ctx)
end)

Trimming output

An output layer runs before the output becomes part of the conversation, so what it drops is never paid for again:

local MAX = 200

maki.api.set_slot("tool.bash.output", function(prev, out, ctx)
  local lines = {}
  for line in out.text:gmatch("[^\n]+") do
    if not line:match("^%s*Compiling ") then
      table.insert(lines, line)
    end
  end
  out.text = table.concat(lines, "\n", 1, math.min(#lines, MAX))
  return prev(out, ctx)
end)

A replacement table has to carry text. Without it the output is left alone and the reason is logged. Set is_error to turn a success into a failure, or a failure into a success.

An output slot fires only when the text is the whole output. Tools the UI renders from fields, like read or edit, are excluded, because prose edited underneath would disagree with the display. So are tools whose result carries structured state saved with the session, like batch or question. That state is what gets re-rendered on restore, so a value redacted in the text would come back after a restart.

Rules

Permissions judge what runs. An input layer runs before the schema check and before rules are resolved, so a layer cannot turn allow bash: git status into something else. The prompt you see names the rewritten call.

A layer borrows the tool's capability. Wrapping tool.bash.input decides what bash runs, so the plugin holding that layer needs run, the capability the bash tool declares. A layer from a plugin without it is skipped and the rest of the chain still runs. The check happens per call, so a missing grant shows up in debug logs rather than as a warning at load.

ToolA layer needs
declares a permission, like bashthat permission
declares none: read, batch, MCP tools, ACP client tools, tool_searchevery permission

Declaring no capability does not mean a tool uses none. batch, code_execution and task declare nothing while invoking any other tool, so reading undeclared as free would hand a plugin everything. Undeclared costs the maximum instead. See plugin permissions.

A layer may wait, within a window. Chains are async, so a layer can read a file or run a job before it decides. It runs inside the call it is filtering, so cancelling the call cancels the layer too. Each stage gets whatever the call has left of its own deadline, capped at 60 seconds. A layer still running when the window closes is dropped, and the call proceeds as if that layer had passed the value along.

Cancellation lands differently on the two stages. An input layer cut short stops the call, because nothing has run yet and nobody is left to read a result. An output layer cut short leaves the output as it found it, since the work is already done.

A broken layer is skipped. If a layer throws, the chain continues as if it had passed the value along, and the error is logged with the plugin name.

Order is registration order. The last layer registered is the outermost one and sees the value first. Package load order decides this, so avoid writing two layers that only work in one order.

prev is single use. Calling it twice throws. Everything below a layer runs once per call.

History keeps the call the model wrote. The tool header, the permission prompt, and the tool result show what ran. If a rewrite changes what the call means, tell the model by appending a line in the output layer.

Idle slots cost nothing. A tool with no layers never crosses into Lua, and a chain that hands back the value it was given leaves the original untouched.

JSON null arrives as nil. A Lua table cannot hold a null, so a null field and a field that was never there look the same inside a layer. Maki carries nulls across for you, which is what makes an untouched value a true no-op. The cost: you cannot delete a field whose value is null, because maki cannot tell that apart from leaving it alone. Set it to another value, or deny the call.

Wrapping every tool

Slot names are per tool, so a layer on tool.bash.input costs nothing when read is called. To cover all of them, loop over the registry:

local function redact(prev, out, ctx)
  out.text = out.text:gsub("sk%-%w+", "[redacted]")
  return prev(out, ctx)
end

for _, tool in ipairs(maki.api.get_tools()) do
  maki.api.set_slot("tool." .. tool.name .. ".output", redact)
end

Most builtins declare no capability, so this loop only does anything for a plugin granted every permission. Run it from a plugin without them and each layer is skipped when its tool is called.

This sees the tools registered so far, so run it from init.lua, which loads after the builtin plugins. It also misses MCP tools, which arrive when their server connects. Naming one slot directly has no such ordering rule: set_slot accepts a name before anything registers it.

Plugin slots

A plugin can define an extension point of its own with declare_slot. The declaring plugin owns the name and supplies the default, and anyone can wrap it with set_slot:

-- owner
local render = maki.api.declare_slot("myplugin.render", function(text)
  return text:upper()
end)

-- anyone
maki.api.set_slot("myplugin.render", function(prev, text)
  return "[" .. prev(text) .. "]"
end)

-- render("hi") now returns "[HI]"

Names starting with tool. are reserved for maki, which fires them at points whose ordering it guarantees.

Use maki.api.get_slots() to see who owns and who wraps each slot.

Limits

A layer has no agent context, so maki.agent.call_tool and maki.agent.session are out of reach inside one. Read files, run jobs, and decide from those.