Writing caudra plugins
Lua plugins are experimental and off by default. Turn them on with lua_plugins = true under [experimental] in the global caudra.toml, then restart Caudra. --no-plugins turns Lua off again for one run. See Experimental features.
Caudra plugins are plain Lua files (Luau) that run inside caudra. A plugin can
register tools the LLM calls, slash commands, keymaps, prompt hints, and
custom UI. Everything lives under the global caudra table. The full API
reference is at the end of this document.
Where plugin code goes
Section titled “Where plugin code goes”Plugins live in one of two config directories with the same layout:
caudra.env.config_dir()- global, every project<project>/.caudra/- this project only
Release builds normally return ~/.config/caudra/. Debug builds return
~/.config/caudra-debug/. Setting CAUDRA_NAMESPACE replaces the directory
name with your own. An active legacy directory takes precedence.
init.lua the only file caudra runs; require()s plugins, calls caudra.setup()lua/<name>.lua plugin modules, loaded by require("<name>")plugin.toml permission grants for every Lua file in the dirNothing 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
Section titled “Creating a plugin”- Write the code in
<config>/lua/<name>.lua, where<config>is the global config directory above. Thecaudraglobal is already there, nothing to import. For a project-only plugin use<project>/.caudra/here and in every step below.
caudra.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>/init.lua, creating that file if missing:
require("hello")- Grant the permissions it needs in
<config>/plugin.toml, creating that file if missing. Without the file every gated call is denied.
[permissions]fs_read = truerun = true- Run
/reload, then read the log as described below, to see that it loaded and what it printed.
Leave caudra.api.register_options to bundled plugins: caudra 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
Section titled “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
Section titled “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 caudra.log.info|warn|error(...) calls. They write to caudra.log
in the directory caudra.env.logs_dir() returns, which Logging
describes and /logs reads live. When
a backtrace comes out useless, start caudra with --no-jit: plugins then run on
the interpreter, with full debug info.
Conventions
Section titled “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. - Return complete
llm_outputfrom tool handlers. The host applies output limits and retains eligible full text for later retrieval. - Set
output_limits = { max_lines = ..., max_bytes = ... }only to override the host limits for one result. Usecaudra.truncateonly when producer-level loss is intentional. - The model picks tools by reading
description, so state precisely what the tool does and when to use it. - Reusable helpers ship with caudra; see "Shared helper modules" in the API reference.
A complete real example
Section titled “A complete real example”The bundled view_image tool, verbatim: schema, header and restore hooks, error
handling, host-managed output limits, collapsible UI view. It is a bundled plugin,
so it opens with register_options, which your own plugin skips:
local shorten_path = require("caudra.shorten_path")
local DESCRIPTION = [[View an image file (png, jpeg, gif, webp) so you can actually see it; it is returned as vision input alongside the tool result. Use instead of `file_read` for images.
- Paths: absolute, relative, or ~/.- Oversized images are downscaled automatically (animated gif/webp keep only the first frame).]]
-- Anthropic rejects images over 5MB base64; 3MB raw is ~4MB encoded,-- which leaves headroom.local MAX_RAW_BYTES = 3 * 1024 * 1024-- Anthropic downscales anything over 1568px on the long edge server-side-- anyway, so ship fewer bytes and do it here.local MAX_EDGE = 1568-- Refuse absurdly large files up front; caudra.image.decode also enforces a-- host-side pixel cap against decode bombs.local MAX_INPUT_BYTES = 50 * 1024 * 1024
local MEDIA_TYPES = { png = "image/png", jpeg = "image/jpeg", gif = "image/gif", webp = "image/webp",}
local function format_size(bytes) if bytes >= 1024 * 1024 then return string.format("%.1fMB", bytes / (1024 * 1024)) end return string.format("%dKB", math.ceil(bytes / 1024))end
local function caption(path, bytes, width, height, note) -- Shortened path, not basename: two screenshot.png in different dirs must -- stay distinguishable when several images land in one turn. return string.format("[image: %s %s %dx%d%s]", shorten_path(path), format_size(bytes), width, height, note or "")end
local function fail(msg) return { llm_output = msg, is_error = true }end
local function load_image(path) local bytes, read_err = caudra.fs.read_bytes(path) if not bytes then return fail("cannot read " .. path .. ": " .. (read_err or "unknown error")) end local size = buffer.len(bytes) if size > MAX_INPUT_BYTES then return fail( string.format("%s is too large to view (%s; limit %s)", path, format_size(size), format_size(MAX_INPUT_BYTES)) ) end
local info, probe_err = caudra.image.probe(bytes) if not info then return fail(path .. " is not an image (" .. (probe_err or "unrecognized format") .. ")") end local media_type = MEDIA_TYPES[info.format] if not media_type then return fail("unsupported image format " .. info.format .. ": only png, jpeg, gif, and webp can be viewed") end
-- Decode fully even on the pass-through path: a corrupt file shipped -- undecoded poisons message history and fails every later request. local img, decode_err = caudra.image.decode(bytes) if not img then return fail("cannot decode " .. path .. ": " .. (decode_err or "unknown error")) end
if size <= MAX_RAW_BYTES and math.max(info.width, info.height) <= MAX_EDGE then return { llm_output = caption(path, size, info.width, info.height), image = { media_type = media_type, data = caudra.base64.encode(bytes) }, } end
-- Too big for the API: downscale to fit MAX_EDGE and re-encode. JPEG stays -- JPEG (photos recompress far smaller); everything else becomes PNG since -- gif/webp encoding isn't supported. local resized = math.max(info.width, info.height) > MAX_EDGE if resized then img = img:resize(MAX_EDGE, MAX_EDGE) end
local out_format = info.format == "jpeg" and "jpeg" or "png" local encoded = img:encode(out_format) if #encoded > MAX_RAW_BYTES and out_format == "png" then -- PNG can stay huge at 1568px (e.g. noisy screenshots); JPEG is the only -- remaining lever. out_format = "jpeg" encoded = img:encode(out_format) end if #encoded > MAX_RAW_BYTES then return fail( string.format( "%s is too large to view (%s after downscaling; limit %s)", path, format_size(#encoded), format_size(MAX_RAW_BYTES) ) ) end
local note = resized and string.format(", downscaled from %dx%d", info.width, info.height) or ", re-encoded" -- Animated gif/webp lose their animation when re-encoded. if info.format == "gif" or info.format == "webp" then note = note .. ", first frame only" end
return { llm_output = caption(path, #encoded, img:width(), img:height(), note), image = { media_type = MEDIA_TYPES[out_format], data = caudra.base64.encode(encoded) }, }end
caudra.api.register_tool({ name = "view_image", effect = "read_only", kind = "read", description = DESCRIPTION, permission_scopes = function(input) return { scopes = { caudra.fs.normalize(input.path) }, force_prompt = false } end, -- No interpreter audience: the python_execution bridge flattens tool output -- to text, so the pixels could never reach the model from there. audiences = { "main", "research_sub", "general_sub" },
schema = { type = "object", properties = { path = { type = "string", description = "Path to the image file", required = true, alias = "file_path", }, }, },
header = function(input) local buf = caudra.ui.buf() buf:line({ { shorten_path(input.path or ""), "path" } }) return buf end,
handler = function(input, _ctx) local raw = input.path if not raw then return fail("error: path is required") end local path = caudra.fs.normalize(raw) local meta = caudra.fs.metadata(path) if not meta then return fail("error: path not found: " .. path) end if meta.is_dir then return fail("error: " .. path .. " is a directory") end return load_image(path) end,})Full API reference
Section titled “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
caudra-plugin-dev skill, so asking it to write a plugin for you works
without pasting any of this.