nervネルフ

Writing a tool

Drop a Lua file in a directory and it is in the next session. A declaration — a name, a description, a parameter schema, and how the body is reached.

plate 04three sources, one registry, four ways to reach a tool

Where the file goes

No entry point to edit, no manifest, no registration. Make the directory if it is not there:

~/.config/magi/plugin/yours.lua                        -- alphabetical, each on its own
~/.local/share/magi/site/pack/*/start/*/plugin/*.lua   -- installed packages
~/.config/magi/after/plugin/yours.lua                  -- the last word

The same three directories exist for casper, melchior and balthasar, holding their own registrars. Every registrar replaces by name, so the order is the precedence: after/plugin/ is how you override something you did not write. Naming a file explicitly still works and is still the auditable case — magi.load, casper's load setting.

A package you fetched does not run until you say so. Files in your own plugin/ directory are yours and run on sight; anything under site/pack/ is held back until magi acknowledge records its digest, and held back again the moment it changes. Fetching is git clone; the idea is the lockfile.

What a declaration owes

magi.tool("branch", {
  description = "The git branch this directory is on.",
  parameters  = { type = "object", properties = {} },
  transport   = { kind = "lua" },        -- the body is the run below, in this VM
  needs       = "run",                   -- read | write | run | reach
  run = function(args)
    local out, err = magi.shell("git branch --show-current")
    if out == nil then return { content = err, is_error = true } end
    return { content = out }
  end,
})
fieldis
descriptionwhat the model is told the tool does. This is the whole of how it decides to call it.
parametersJSON Schema for the arguments. The model is held to it before run is called.
transporthow the body is reached. Not optional and no default — see below.
needsthe permission verb this acts under. Omit it for a tool that touches nothing a person would want a say over.
runthe body, for a lua transport. Returns { content = … }, or { content = …, is_error = true }.

There is no os.execute and no io.popen in the VM, and no io at all. magi.shell is the seam commands go through — the same ledger the shell tool passes, the same refusals — and magi.fs.write is the one for files. Both answer nil, why rather than raising.

Four ways to be reached

transportisfor
luaa body that runs in magi's own VManything that is a few lines of logic
commanda program on $PATH, given the call as argvwrapping something that already exists
casper<program> run <tool>, one exec per callwhatever the tools role’s program offers — casper’s thirteen, unless magi.tools names another
builtincompiled into magithe floor: read, write, edit

The registry does not care which. One name, one entry, and the model sees the same declaration whichever way it is reached. A tool with a run and no transport is refused at load — the registry has no way to guess that the function is the point.

said and shown

A casper tool returns said and shown rather than content, and the split is the whole idea. said enters the transcript, is replayed on every subsequent request, and costs context every turn until the conversation is compacted. shown is drawn once and costs nothing.

casper.tool("patch", {
  description = "Show the difference between two files.",
  parameters  = { type = "object", properties = { old = { type = "string" } } },
  needs = "read",
  run = function(args)
    local done = casper.exec("diff", { "-u", args.old, args.new })
    return {
      said  = "3 files differ",                        -- the model reads this
      shown = casper.paint.ansi(done.out, casper.theme), -- the person sees this
    }
  end,
})

A tool that returns a hundred lines of diff as said has spent that budget for the rest of the session. The same diff as shown, with said = "patched 3 files", costs four words. And a tool never names a colour: it says what its output means — added, keyword, path — and the harness resolves that against its own palette.

Two caps you do not opt out of

Here rather than in the tools, because a peer is another program and cannot be trusted to cap itself, a Lua tool has no way to write a spill file, and a shipped declaration has no knob to set. Every result of every transport passes through one place, which is the only place that is true of.

Watching a session

The other registrar. A watcher is told after the fact and answers with nothing: it cannot change a result and cannot fail one, so observing a session is not a way to break it.

magi.watch("timing", {
  run = function(event)
    if event.kind ~= "turn.ended" then return end
    magi.fs.write(os.getenv("HOME") .. "/.local/state/magi/last", event.took_ms .. "ms\n")
  end,
})

Branch on event.kind and nothing else. There are eight: session.opened, turn.began, turn.ended, tool.finished, permission.asked, permission.answered, context.compacted and provider.retried.

A tool that asks a question

A declaration reads args.answered to know it is resuming. The answer travels with the arguments rather than beside them — merged, so a declaration writes args.answered and not args.call.answered. The answer is one more thing known about this call, which is what an argument is.

What is stable

magi verbs reports surface, which versions everything on this page. It is 1. Adding a registrar, a field or an event does not move it — a file written against 1 keeps running. Renaming one, removing one, or changing what a field means does.