Lua Scripting skill for Codex
Lua for Neovim, embedding and scripting hosts. Codex first works out which Lua it is writing for, LuaJIT in Neovim, Lua 5.4, or an embedded host, and sticks to that version's features. Code keeps everything local, modules return tables, untrusted scripts load in text-only sandboxes, C hosts call Lua through lua_pcall with balanced stacks, and Neovim configs use augroups, vim.keymap.set and the built-in LSP config.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- lua-scripting Installs to ~/.agents/skills/lua-scripting/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install lua-scripting The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project lua-scripting Installs to .agents/skills/lua-scripting/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/lua-scripting.zipunzip -o lua-scripting.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) lua-scripting PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $lua-scripting, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes Lua that works on the runtime it targets - Lua 5.4, LuaJIT/5.1 in Neovim, or an embedded host - with locals and modules done right, tables, metatables, closures, error handling with pcall, sandboxed environments, the C API for embedding, and Neovim config with vim.keymap, autocmds, vim.lsp.config and lazy.nvim. Use when writing a Neovim init.lua or plugin, embedding Lua in a C or C++ application, scripting a game or tool that exposes Lua, or linting Lua with luacheck, selene and StyLua.
Use it when
- Writing or refactoring a Neovim init.lua or plugin
- Embedding Lua in a C or C++ application
- Scripting a tool, game or service that exposes Lua
- Running untrusted user Lua in a sandbox
Use something else when
- The code is Roblox Luau: use roblox-luau
- You are managing dotfiles across machines rather than Lua code: use dotfiles-management
How to check the result
StyLua and luacheck or selene pass with no new globals, and tests or a headless Neovim smoke run succeed on the target Lua runtime.
The mistake it prevents
Using Lua 5.4 features like integer division or table.unpack-only code in Neovim, which runs LuaJIT with 5.1 semantics.
Good Lua is written for a specific runtime: every variable is local unless it must be
global, modules return a table, errors are raised with useful messages and caught at a
deliberate boundary, and the code only uses features the host’s Lua version has.
First, identify the runtime
| Host | Lua version | Consequences |
|---|---|---|
| Neovim | LuaJIT (5.1 semantics + some 5.2 extensions) | no //, no <const>/<close>, write local unpack = table.unpack or unpack, bit library for bitops, vim.* APIs |
Standalone lua | usually 5.4 | integers vs floats, //, <const> and <close> locals, table.unpack only |
| Embedded in an app or game | whatever the host links (often 5.1/LuaJIT or 5.4) | check the host docs; globals and require may be restricted |
| Roblox | Luau (typed dialect) | different stdlib and type syntax; not covered here |
Redis EVAL | 5.1 | no globals, keys passed via KEYS/ARGV |
Check with lua -v, nvim --version, or print(_VERSION, jit and jit.version).
Read AGENTS.md, .luarc.json, .luacheckrc / selene.toml and stylua.toml if present.
Core idioms
-- mymod/init.lua (or mymod.lua)
local M = {}
local DEFAULTS = { retries = 3, timeout_ms = 2000 }
---@param opts? { retries?: integer, timeout_ms?: integer }
function M.setup(opts)
M.config = vim and vim.tbl_deep_extend("force", DEFAULTS, opts or {})
or setmetatable(opts or {}, { __index = DEFAULTS })
end
local function backoff(attempt) -- private: local, not in M
return math.min(2 ^ attempt * 100, 5000)
end
function M.fetch(url)
for attempt = 1, M.config.retries do
local ok, result = pcall(do_request, url, M.config.timeout_ms)
if ok then return result end
sleep_ms(backoff(attempt))
end
error(("fetch %s failed after %d attempts"):format(url, M.config.retries), 2)
end
return M
localeverything. A missinglocalcreates a global that leaks across modules; luacheck and selene flag it.- Modules return a table; never set globals from a module.
- Cache hot globals as locals in performance-sensitive files (
local insert = table.insert). error(msg, 2)blames the caller’s line; raise tables (error({code = "E_TIMEOUT"})) when callers need to branch on the error.- Use
pcall/xpcall(fn, debug.traceback)at boundaries (event handlers, plugin entry points), not around every call.
Tables and metatables
local Stack = {}
Stack.__index = Stack
function Stack.new() return setmetatable({ n = 0 }, Stack) end
function Stack:push(v) self.n = self.n + 1; self[self.n] = v end
function Stack:pop()
if self.n == 0 then return nil end
local v = self[self.n]
self[self.n] = nil
self.n = self.n - 1
return v
end
- Arrays are 1-based.
#tis only defined for sequences without holes; track length yourself (n) whennilvalues are possible, or useselect("#", ...)for varargs. ipairsstops at the firstnil;pairsorder is unspecified, so sort keys before producing output that must be deterministic.a.bvsa:b(): the colon passesself. Mixing them is the most common “attempt to index a nil value” source.- Only
nilandfalseare falsy.0and""are true. x = x or defaultbreaks whenfalseis a legitimate value.- String building in loops: collect into a table and
table.concat, not repeated...
Sandboxing untrusted scripts (5.2+)
local SAFE = { string = string, table = table, math = math, pairs = pairs,
ipairs = ipairs, tostring = tostring, tonumber = tonumber, type = type }
local function run_untrusted(code)
local env = setmetatable({}, { __index = SAFE })
local chunk, err = load(code, "=user", "t", env) -- "t": text only, no bytecode
if not chunk then return nil, err end
return pcall(chunk)
end
Never load untrusted bytecode (mode "b"), never expose os, io, load,
require, debug, package or string.dump. Sandboxing does not bound CPU or memory;
use debug.sethook instruction counts or host-side limits. On 5.1/LuaJIT use
setfenv(chunk, env) instead of the env argument.
Embedding Lua in C
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
static int l_log(lua_State *L) {
const char *msg = luaL_checkstring(L, 1); /* raises a Lua error on bad type */
host_log(msg);
return 0; /* number of return values */
}
int run_script(const char *path) {
lua_State *L = luaL_newstate();
if (!L) return -1;
luaL_openlibs(L); /* or open only the libs you allow */
lua_register(L, "host_log", l_log);
int rc = luaL_loadfile(L, path) || lua_pcall(L, 0, 0, 0);
if (rc != LUA_OK) {
fprintf(stderr, "lua: %s\n", lua_tostring(L, -1));
lua_pop(L, 1);
}
lua_close(L);
return rc;
}
- Every API call has a documented stack effect; check
lua_gettop(L)before and after in debug builds. - Call into Lua with
lua_pcall, neverlua_call, from host code: an error inlua_calllongjmps out of your C stack and skips C++ destructors. - Keep references to Lua values across calls with
luaL_ref(L, LUA_REGISTRYINDEX)and release them withluaL_unref. - A
lua_Stateis not thread-safe; one state per thread or a lock around all access. - Expose host objects as full userdata with a metatable (
luaL_newmetatable,luaL_checkudata) and a__gcthat frees the C resource.
Neovim configuration
-- ~/.config/nvim/init.lua
vim.g.mapleader = " "
vim.opt.number = true
vim.opt.expandtab = true
vim.opt.shiftwidth = 2
vim.keymap.set("n", "<leader>w", "<cmd>write<cr>", { desc = "Save file" })
local group = vim.api.nvim_create_augroup("user_format", { clear = true })
vim.api.nvim_create_autocmd("BufWritePre", {
group = group,
pattern = { "*.lua", "*.go" },
callback = function(args) vim.lsp.buf.format({ bufnr = args.buf }) end,
})
-- Neovim 0.11+: built-in LSP config
vim.lsp.config("lua_ls", {
settings = { Lua = { diagnostics = { globals = { "vim" } } } },
})
vim.lsp.enable({ "lua_ls", "gopls" })
- Always pass an augroup with
clear = true, or re-sourcing the config duplicates autocmds. - Use
vim.keymap.setwithdesc(shows in which-key and:map), notnvim_set_keymapwith string RHS. - Long-running work:
vim.system({...}, {}, on_exit)(0.10+) orvim.uv(wasvim.loop); touch the UI from callbacks only insidevim.schedule. - Plugin layout:
lua/<name>/init.luawithM.setup(opts), and lazy-load via the plugin manager the user has (lazy.nvim specs withevent,cmd,keys,opts). Check:checkhealthafter changes. - On older versions without
vim.lsp.config, the user likely uses nvim-lspconfig; checknvim --versionbefore rewriting their LSP setup.
Tooling and verification
stylua --check . # formatting (stylua.toml)
luacheck . # or: selene . (globals, unused vars, shadowing)
lua-language-server --check . # type annotations (---@param) if the repo uses LuaLS
busted # tests, if spec/ exists
nvim --headless -c 'lua require("myplugin").setup()' -c 'qa!' # smoke-test a plugin
- No new globals reported by luacheck/selene (configure
vimas a read global for Neovim code). - Tests or a headless smoke run pass on the target runtime version.
- Embedded code: run the host under ASan once to catch stack misuse in the C side.
- Report the runtime and version targeted.
Installing these tools (luarocks install luacheck busted, cargo install stylua)
needs network access; ask first.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Lua Scripting"
short_description: "Lua for Neovim, embedding and scripting hosts"
default_prompt: "$lua-scripting Convert my Neovim vimscript config to init.lua" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.