Skip to content

Lua Language Basics

Crucible embeds PUC Lua 5.4 (via the mlua crate) for plugin development, with optional Fennel support.

Why Lua?

Lua is one of the most widely-used scripting languages, with simple syntax that’s easy for both humans and LLMs to write. If you want AI to generate your plugins, Lua is an excellent choice.

Key Features

  • Simple syntax: Easy to learn if you know JavaScript or Python
  • Fennel support: Write in Lisp syntax, compile to Lua
  • LLM-friendly: Models generate high-quality Lua code

The cru Namespace

All built-in modules live under the cru namespace — the one Lua global. There are no standalone globals: http, fs, shell, paths and graph were removed, and referencing one is a nil-index error naming the field.

Crucible adds what Lua lacks and nothing more. Reading and writing files is io’s job, joining strings is the language’s, and formatting is string.format — so cru.fs.read/write/append/rename, cru.paths.join and cru.fmt are gone.

-- Canonical access
cru.http.get(url)
cru.shell.exec("git", {"status"})
cru.log("info", "message")
cru.json.encode(tbl)
cru.json.decode(str)
-- Files are plain Lua
local f = assert(io.open(path, "r"))
local body = f:read("a")
f:close()

[!warning] One known divergence: config cru.config.get(key) reads a single top-level app-config value (the merged config.toml + cru.config.set() state, no dotted paths), while cru.plugin.config.get("plugin.key") — registered on the daemon’s plugin VM — does dotted-key descent into [plugins.*] config. Same name, different semantics; pick by what you’re reading, not by namespace habit.

Core Modules

ModuleDescription
cru.log(level, msg)Logging ("debug", "info", "warn", "error")
cru.jsonencode(table), decode(string), and array(table) (mark a table as a JSON list so an empty one encodes as [], not {})
cru.httpHTTP client: get, post, put, patch, delete, request
cru.wsWebSocket client: connect(url, opts?) returning a connection object
cru.fsThe filesystem gap Lua’s io does not cover: exists, is_file, is_dir, list, mkdir, copy, remove_all. Read and write with io.
cru.shellShell command execution
cru.oqData query/transform: parse, yaml, toml, toon, query, format (JSON is cru.json)
cru.pathsDirectories the host owns: config, workspace, session, state(plugin). Join with ...
cru.kilnKiln access
cru.sessionDaemon session management (create, send messages, subscribe to events)

Kiln-Addressed Paths

A plugin addresses a kiln by NAME and asks the daemon to resolve it: cru.kiln.path(name, relative?). The name comes from something the plugin already knows — a session’s kilns array, or cru.kiln.active.

The kiln:// URL scheme is removed. It never said WHICH kiln registry to consult and it collided with plain relative paths, so one function replaced it. Every surviving cru.fs function refuses a kiln:// prefix with an error naming the replacement, rather than treating it as a relative path and silently creating a ./kiln:/... directory.

local root = cru.kiln.path("notes")
local dir = cru.kiln.path("notes", ".crucible/proposals")
cru.fs.mkdir(dir)
local f = assert(io.open(dir .. "/idea.md", "w"))
f:write(body)
f:close()

The relative part must be plain components — .., . and absolute parts are refused. That is a bug lint, not a boundary: a plugin builds the relative half from pieces it already knows, so a .. there is a mistake worth reporting.

Plugin & Agent Modules

ModuleDescription
cru.storagePlugin-scoped key-value store: set(entity, key, val), get(entity, key), list(entity), find(key, val), delete(entity, key)
cru.scheduleInterval tasks: cru.schedule({every=N}, fn) returns handle; cru.schedule.cancel(handle)
cru.toolsTool registry: get_tools(), run(name, args)
cru.log.notifyNotifications: notify(msg, level?, opts?), notify_once(msg)
cru.log.messagesNotification panel: toggle(), show(), hide(), clear()
cru.oilUI building: text(), col(), row(), spacer(), maybe(), match_state()
cru.errorsPlugin error log: recent(n?) returns recent errors

Utility Modules

ModuleDescription
cru.timersleep(secs), timeout(secs, fn), clock()
cru.ratelimitnew({capacity, interval}) returning limiter with :acquire(), :try_acquire(), :remaining()
cru.retry(fn, opts)Exponential backoff retry (opts: max_retries, base_delay, max_delay, jitter, retryable)
cru.emitter.new()Event emitter with :on(event, fn), :once(event, fn), :off(event, id), :emit(event, ...)
cru.checkArgument validation: .string(val, name), .number(val, name, opts), .boolean(val, name), .table(val, name), .func(val, name), .one_of(val, options, name) — all support {optional=true}
cru.timer.spawn(fn)Spawn an async function as an independent tokio task (daemon context only)
cru.inspect(value, opts?)Pretty-print any value with cycle detection (<cycle: table>); opts: max_depth, indent. Also available as the global inspect
cru.tbl_deep_extend(behavior, ...)Deep-merge tables into a new table; behavior is "force" (last wins) or "keep" (first wins)
cru.tbl_get(t, ...)Safe nested access: cru.tbl_get(cfg, "a", "b", "c") returns the value or nil if any step is missing or not a table
cru.on_errorReserved error-handler slot, initialized to nil. Assignable, but nothing invokes it yet

Timer

The cru.timer module provides async timing primitives backed by tokio::time.

cru.timer.sleep(seconds)

Async sleep that yields the coroutine without blocking the runtime.

cru.timer.sleep(2.5) -- yields for 2.5 seconds

The seconds argument must be a finite non-negative number.

cru.timer.timeout(seconds, fn)

Run a function with a deadline. Returns (true, result) on success, (false, error_string) on error, or (false, "timeout") if the deadline expires.

local ok, result = cru.timer.timeout(5.0, function()
return cru.http.get("https://slow-api.example.com")
end)
if not ok then
cru.log("warn", "Request failed: " .. tostring(result))
end

cru.timer.clock()

Returns monotonic wall-clock time in seconds (f64) since the Lua runtime started. Unlike os.clock() which returns CPU time, this returns wall time that advances even when the Lua VM is yielded at async points. Useful for timing and measuring elapsed durations.

local start = cru.timer.clock()
cru.timer.sleep(1.0)
local elapsed = cru.timer.clock() - start -- ~1.0

Async Task Spawning

cru.timer.spawn(fn)

Spawns an async Lua function as an independent tokio task (fire-and-forget). The function runs concurrently with the caller. Only available when running in daemon context with the send feature enabled.

This is needed when event handlers (called via pcall) need to perform async operations that require yielding, such as cru.session.subscribe() or cru.session.send_message(). Since pcall/xpcall create a yield barrier, spawning the async work as a separate task is the workaround.

-- Inside a gateway event handler (runs under pcall):
cru.timer.spawn(function()
local next_event, err = cru.session.subscribe(session_id)
cru.session.send_message(session_id, content)
while true do
local event = next_event()
if not event then break end
-- process event
end
end)

Errors in the spawned function are logged as warnings but do not propagate to the caller.

Session API

The cru.session module provides full session management for daemon plugins. All functions are async and follow the convention of returning (result, nil) on success or (nil, error_string) on failure. Without a daemon connection, all calls return (nil, "no daemon connected").

See Lua Runtime API for the complete reference.

Quick example

-- Create a session
local session, err = cru.session.create({ type = "chat" })
-- Configure the agent
cru.session.configure_agent(session.id, {
model = "claude-sonnet-4-20250514",
system_prompt = "You are a helpful assistant.",
})
-- Subscribe to events BEFORE sending the message
local next_event, err = cru.session.subscribe(session.id)
-- Send a message (triggers agent processing)
local msg_id, err = cru.session.send_message(session.id, "Hello!")
-- Read streaming events
while true do
local event = next_event()
if not event then break end
if event.type == "text_delta" then
-- event.data.text contains the chunk
elseif event.type == "message_complete" then
break
end
end
cru.session.unsubscribe(session.id)
cru.session.end_session(session.id)

Fennel

Fennel is a Lisp that compiles to Lua. Use .fnl files if you prefer Lisp syntax with Lua’s runtime.

Resources

See Also