404 lines · 9.0 KB · Markdown
RawDownload

CLM-TOOL(5) - File Formats Manual

NAME

clm-tool - clm Lua plugin and tool API

DESCRIPTION

clm(1) loads every *.lua file in ~/.config/clm/plugins/ (or a directory given with --plugins, or an agent-specific ~/.config/clm/agents/‌agent‌/ directory) as a plugin. Each plugin file gets its own sandboxed lua(1) state and is expected to call clm.tool_register at least once, at load time, to register the tools it provides.

Sandbox

Only the string, table, math, and utf8 standard libraries are loaded; os and io are never loaded at all, and dofile, loadfile, load, require, and collectgarbage are removed from _G after loading the safe libraries. There is no filesystem or process access except through clm.read_file and clm.write_file (below), no dynamic code loading, and no way to reach any other plugin's state.

A plugin has an 8 MiB private memory cap, a 500 ms deadline to finish executing its top-level (load-time) code, and a separate, per-tool-call CPU deadline (the timeout_ms field of clm.tool_register, below; 30 s if unset), checked every 10000 VM instructions so a tight infinite loop cannot dodge it. Exceeding either deadline aborts execution of that plugin or call; a load-time failure is logged and that one plugin's tools are simply not registered; the rest of the plugin directory still loads.

Registering a tool

clm.tool_register(name, {
    description = "...",       -- optional
    params_schema = { ... },   -- optional, a JSON-Schema-shaped table
    invoke = function(args, ctx)
        ...
        ctx:complete(result)   -- or ctx:fail(message)
    end,
    timeout_ms = 30000,        -- optional, overrides the default
    no_prompt = false,         -- optional, skip the permission prompt
    hidden = false,            -- optional, hide from the model's schema
})

name must be unique across every plugin and builtin tool loaded into the agent. invoke is called once per tool invocation with args (the decoded JSON arguments the model supplied, following params_schema) and ctx, described below. exactly one of ctx:complete or ctx:fail must be called, from invoke or from a child started with clm.spawn() (see HTTP requests below); calling either a second time is an error.

By default a tool is gated behind an interactive allow/deny prompt the first time it is called in a session (see clm_agent(3)'s on_permission callback); set no_prompt = true for a tool with no side effects worth confirming. hidden = true omits the tool from the schema advertised to the model entirely, for a tool meant to be invoked only by other plugin code, not by the model itself.

The ctx object

ctx:complete(result)

Ends the invocation successfully. result is a string; the model sees it as the tool's output.

ctx:fail(message)

Ends the invocation with a failure the model sees as "[tool failed: X]" (where 'X' is message).

ctx:args_raw()

Returns the raw, undecoded JSON arguments string ("{}" if the model supplied none) . Most plugins use the decoded args table passed to invoke instead; this is for a plugin that wants to reparse or forward the arguments verbatim.

ctx:log(message)

Writes message to the debug log (see CLM_DEBUG_LOG in clm(1)). A no-op, at negligible cost, when that variable is unset.

HTTP requests

local resp, err = http.get(url)
local resp, err = http.get(url, {["Authorization"] = "Bearer ..."})
local resp, err = http.post(url, body)
local resp, err = http.post(url, body, {["Content-Type"] = "text/plain"})

both yield the calling coroutine and resume it once the request completes, so invoke does not block the agent's event loop while a request is in flight. they may be called from the main tool coroutine or from a child started with clm.spawn(), but not at plugin load time. headers is an optional table of header-name to header-value strings; http.post() seeds "Content-Type: application/json" by default unless headers overrides it.

On success, resp is a table with resp.status (the HTTP status code) and resp.body (the response body as a string); err is nil. On failure, resp is nil and err is a string describing what went wrong. Up to 8 requests per plugin may be in flight concurrently, and a single tool call may make at most 128 requests in total; exceeding either limit fails the request rather than queuing it.

Other clm module functions

clm.read_file(path)

clm.write_file(path, content)

Read or write a file, without the sandbox's normal lack of filesystem access. Available at any point in a plugin, not just from within invoke.

clm.sleep(ms)

yield the calling coroutine for ms milliseconds without blocking the event loop. same coroutine restriction as HTTP requests above.

clm.spawn(function)

create and return a task handle for function. the function is scheduled for a later event-loop iteration and does not execute before clm.spawn() returns. the task belongs to the current tool invocation and may call http.get(), http.post(), and clm.sleep(). only the main tool coroutine may spawn tasks; a task may not spawn another task. values returned by function become the task result.

task:wait()

wait until task succeeds, fails, or is cancelled, then return true. waiting does not read or interpret the task result.

task:result()

return nil if task is pending. a terminal task returns a stable outcome table. a successful outcome has ok set to true and one normalized value field: one returned value is stored directly, multiple returned values are stored in an array, and zero returned values omit value. a failed outcome has ok set to false and an error string. a cancelled outcome also has cancelled set to true. reading a failed outcome marks its error as observed.

task:cancel(reason)

cancel a pending task and its asynchronous operation. reason is optional and defaults to "task cancelled". return true when the task was cancelled or was already cancelled, and false when it had already succeeded or failed.

clm.wait_any(tasks, timeout_ms)

wait until any task in the array table tasks is terminal and return its one-based input index. timeout_ms is optional; expiration returns nil plus "timeout" without cancelling any task. all tasks must be unique handles from the current invocation, and at most 128 tasks may be waited on at once.

clm.await_all(tasks)

wait for every task and return an ordered array of outcome tables as described under task:result(). task failure does not cancel or otherwise affect another task.

clm.try_all(tasks)

wait for tasks until all succeed or one fails. on success, return normalized task values in input order. on failure, cancel every unfinished task in this set and return nil plus the observed error string.

only the main tool coroutine may wait, read results, cancel tasks, or complete or fail the tool. returning from the main invoke function with live tasks cancels those tasks and fails the tool. successfully completing a tool with live tasks or unobserved task errors also fails the tool; clm.spawn() does not create detached background work. the invocation remains alive while its main coroutine or child tasks are suspended; tool cancellation, timeout, and plugin teardown cancel the whole family and its pending asynchronous work.

The json module

local text = json.encode(value)
local value, err = json.decode(text)

A global json table over json-c(3). json.null is a sentinel light userdata value representing JSON null, distinct from Lua nil. json.decode rejects input larger than 2 MiB.

Per-tool configuration

If the plugin's file or tool name appears as a key under tools in config.lua (see clm-config(5)), that subtable is available inside the plugin as clm.config. A plugin with no matching tools entry sees clm.config as an empty table, not nil.

EXAMPLES

-- ~/.config/clm/plugins/hello.lua
clm.tool_register("hello", {
    description = "Say hello to someone",
    params_schema = {
        type = "object",
        properties = {
            name = { type = "string", description = "who to greet" },
        },
        required = { "name" },
    },
    invoke = function(args, ctx)
        ctx:complete("Hello, " .. args.name .. "!")
    end,
})

local tasks = {
    clm.spawn(function()
        local resp, err = http.get(url_a)
        if resp == nil then error(err) end
        return resp.body
    end),
    clm.spawn(function()
        local resp, err = http.get(url_b)
        if resp == nil then error(err) end
        return resp.body
    end),
}
local outcomes = clm.await_all(tasks)
for _, outcome in ipairs(outcomes) do
    if not outcome.ok then
        ctx:fail(outcome.error)
        return
    end
end
ctx:complete(outcomes[1].value .. outcomes[2].value)

SEE ALSO

clm(1), clm_agent(3), clm-config(5)

clm - July 6, 2026