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.
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.
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,
})
| field | is |
|---|---|
description | what the model is told the tool does. This is the whole of how it decides to call it. |
parameters | JSON Schema for the arguments. The model is held to it before run is called. |
transport | how the body is reached. Not optional and no default — see below. |
needs | the permission verb this acts under. Omit it for a tool that touches nothing a person would want a say over. |
run | the 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
| transport | is | for |
|---|---|---|
lua | a body that runs in magi's own VM | anything that is a few lines of logic |
command | a program on $PATH, given the call as argv | wrapping something that already exists |
casper | <program> run <tool>, one exec per call | whatever the tools role’s program offers — casper’s thirteen, unless magi.tools names another |
builtin | compiled into magi | the 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
- Output is bounded. Beyond the limit, the middle is dropped — head and tail both kept, because which one matters depends on the tool: a file read wants its head, a build that failed wants its tail. The whole of it is spilled to a file the note names.
- Secrets are masked by value. The actual contents of this process's credential variables are replaced by their names before anything enters a transcript. Not by pattern — by value, so a key that does not look like a key is caught too.
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.