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.

  • Languages
  • $lua-scripting
  • 204 lines
  • 2 files
  • ~1,950 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- lua-scripting

Installs to ~/.agents/skills/lua-scripting/ for every project on this machine.

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

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.

~/.agents/skills/lua-scripting/SKILL.md

--- name: lua-scripting description: 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,… license: MIT ---

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

HostLua versionConsequences
NeovimLuaJIT (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 luausually 5.4integers vs floats, //, <const> and <close> locals, table.unpack only
Embedded in an app or gamewhatever the host links (often 5.1/LuaJIT or 5.4)check the host docs; globals and require may be restricted
RobloxLuau (typed dialect)different stdlib and type syntax; not covered here
Redis EVAL5.1no 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
  • local everything. A missing local creates 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. #t is only defined for sequences without holes; track length yourself (n) when nil values are possible, or use select("#", ...) for varargs.
  • ipairs stops at the first nil; pairs order is unspecified, so sort keys before producing output that must be deterministic.
  • a.b vs a:b(): the colon passes self. Mixing them is the most common “attempt to index a nil value” source.
  • Only nil and false are falsy. 0 and "" are true.
  • x = x or default breaks when false is 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, never lua_call, from host code: an error in lua_call longjmps 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 with luaL_unref.
  • A lua_State is 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 __gc that 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.set with desc (shows in which-key and :map), not nvim_set_keymap with string RHS.
  • Long-running work: vim.system({...}, {}, on_exit) (0.10+) or vim.uv (was vim.loop); touch the UI from callbacks only inside vim.schedule.
  • Plugin layout: lua/<name>/init.lua with M.setup(opts), and lazy-load via the plugin manager the user has (lazy.nvim specs with event, cmd, keys, opts). Check :checkhealth after changes.
  • On older versions without vim.lsp.config, the user likely uses nvim-lspconfig; check nvim --version before 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 vim as 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem