# Configuration
Lug is configured in Lua 5.4. lug --init writes a commented starter file to
~/.config/lug/init.lua; it will not overwrite one that exists.
Lug uses the XDG directories on Linux and macOS alike: ~/.config/lug for your
configuration, ~/.local/share/lug for plugins and ~/.local/state/lug for sessions,
trust and approvals, or wherever XDG_CONFIG_HOME, XDG_DATA_HOME and XDG_STATE_HOME
point. Lug 0.0.1 kept all three in ~/Library/Application Support/lug on macOS, and
keeps using that directory while it holds an init.lua and ~/.config/lug does not.
To move, put its init.lua, lug-lock.json and lua/ in ~/.config/lug; plugins
clone again on the next start, and project trust is asked again.
Sessions are streams in ~/.local/state/lug/streams/session/ (see
Streams). Lug 0.0.1 kept them in ~/.local/state/lug/sessions/;
the first lug or lug ls to start converts those files, leaving any another lug has
open for a later start.
## Load order
Lug's own defaults are Lua too: the stock runtime,
basic.lug, a plugin like any other that your
init.lua lists first. In order:
~/.config/lug/init.lua;- the project's
.lug/init.lua, once you trust it (below); - each
--cmd LUAgiven on the command line.
require("haul").setup loads the stock runtime where your file calls it, so a plain assignment
after that call wins. An error in any of these is shown as a notice with the file and
line, and loading goes on with the next file. lug --safe loads only the stock runtime,
in place of all three. :reload runs the whole order again (not during a run).
require finds modules in ~/.config/lug/lua/, then in each plugin haul loaded,
then in the few modules built into the binary (lug.bsp and lug.bootstrap), so your own
modules shadow a plugin's, the stock runtime's among them.
## The stock runtime
basic.lug holds Lug's keymaps, commands, windows, statusline, themes and pickers. The
starter init.lua (from lug --init or the installer) lists it, so the first start
installs it. If Lug starts with no haul and nothing has opened a window, as with no
init.lua at all, it offers to install it: y adds the lines below (under Plugins) to
the top of your init.lua (writing one if there is none) and reloads, and n quits.
It is a separate repository with its own history. Like any plugin it follows its default
branch unless you pin a tag or commit, and :haul update basic moves it on. To
change one of its plugins, shadow the module (for example
~/.config/lug/lua/lug/layout.lua); to replace it, list another runtime instead.
## Project config and trust
A project can carry .lug/init.lua. Since the model's tools can write to the project,
Lug loads that file only once you have trusted its exact contents: at startup it asks,
showing the path and offering to view the file. Yes records the file's SHA-256 in
~/.local/state/lug/trust; no skips it for this session. If the file changes, Lug asks
again.
## Plugins
Plugins are git repos that haul installs, pins in
~/.config/lug/lug-lock.json and loads in the order you list them. haul is a plugin too,
so your init.lua clones it the first time, as the starter's does:
local haul = lug.dirs.data .. "/plugins/haul"
package.path = haul .. "/lua/?.lua;" .. haul .. "/lua/?/init.lua;" .. package.path
if not io.open(haul .. "/.git/HEAD") then
for _, url in ipairs({ "git@github.com:klaatu01/haul.lug.git", "https://github.com/klaatu01/haul.lug" }) do
local clone = { "env", "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -o BatchMode=yes -o ConnectTimeout=10", "git", "clone", "--quiet", url, haul }
if lug.proc.run(clone).code == 0 then break end
end
end
require("haul").setup({
"klaatu01/haul.lug", -- haul itself, so :haul update moves it on too
"klaatu01/basic.lug",
"someone/lug-git",
{ "someone/lug-tasks", tag = "v1.2.0", config = function(tasks) tasks.setup({}) end },
{ dir = "~/src/my-plugin.lug" }, -- a local folder, loaded as it is
})
A plugin lists the plugins it needs in a haul.lua at its root, return { dependencies = { "someone/lug-json" } }, and haul installs and loads those first. :haul opens the
plugins panel and :haul update [NAME] moves them on. haul's README has the rest: specs,
pinning, local plugins and the panel's keys.
Plugins run with your full authority and the whole Lua standard library, with no sandbox. Install only plugins you would trust with your shell. Lug's own features (the layout, statusline, keymaps, commands, themes, the session picker) are plugins of this kind in basic.lug, and they are the best examples of the API.
## Models and keys
model is provider/model. There is none by default: Lug starts, and sending says to
set one. LUG_MODEL, the model option and --model set it, the later winning.
| Provider | Example | Key variable | Base URL variable, default |
|---|---|---|---|
| Anthropic | anthropic/claude-opus-5 |
ANTHROPIC_API_KEY |
ANTHROPIC_BASE_URL, https://api.anthropic.com |
| OpenAI | openai/<model> |
OPENAI_API_KEY |
OPENAI_BASE_URL, https://api.openai.com/v1 |
| OpenRouter | openrouter/<vendor>/<model> |
OPENROUTER_API_KEY |
https://openrouter.ai/api/v1 |
| Ollama | ollama/<model> |
OLLAMA_API_KEY (optional) |
OLLAMA_API_BASE_URL, http://localhost:11434 |
| OpenCode Go | opencode-go/kimi-k3 |
OPENCODE_API_KEY |
https://opencode.ai/zen/go/v1 |
A key is looked up in this order:
the
providersoption, which can also set a base URL:lug.opt.providers = { ["opencode-go"] = { api_key = "sk-..." }, ollama = { base_url = "http://gpu-box:11434" }, }~/.lug/auth.json, which:login PROVIDER KEYwrites (owner-only);the environment variable in the table.
Keys are read when a run starts, so a change applies from the next message.
providers can also add a provider that speaks one of the APIs Lug has a client for,
its wire: anthropic (the Messages API), openai (the Responses API), openai-chat
(Chat Completions, which most OpenAI-compatible servers speak), openrouter or
ollama. It needs a base_url and a wire; a key is optional, from api_key or
:login.
lug.opt.providers = {
["local"] = { wire = "openai-chat", base_url = "http://localhost:8080/v1" },
}
lug.opt.model = "local/qwen3"
A provider that serves no /models endpoint, as a gateway often does not, has no listing
for :model and its picker to offer. Name its models and they become its listing:
lug.opt.models = {
["local"] = { "qwen3", "qwen3-coder" },
}
These lead the listing wherever a provider does serve one, and what it says of them, such as a context window, is kept.
Lug asks every provider to cache the prompt, so a run pays for the transcript it has
already sent at the cache's price rather than in full. On the Messages API it sends
Anthropic's automatic cache_control, which moves the cache breakpoint along as the
conversation grows; the OpenAI wires cache a repeated prefix on their own. cached_input_tokens
in turn_finished and run_finished says how much of a turn was served from the cache.
## Options
Read and assign options through lug.opt. A bad value or an unknown name raises an error
and the old value stands. :set lists them all, :set NAME shows one and
:set NAME=VALUE sets one.
| Option | Type | Default | Meaning |
|---|---|---|---|
model |
provider/model, or "" for none |
$LUG_MODEL, else "" |
The model (applies from the next run); with none, Lug starts and sending says to set one |
model_roles |
table: role → provider/model |
{} |
What a plugin's "@role" model means, fast or strong say. A role not set is the model option. |
providers |
table: provider → { api_key, auth, base_url, wire } |
{} |
Keys and base URLs, over the environment, and providers you add; auth = "bearer" also sends the key as Authorization: Bearer |
models |
table: provider → list of model ids | {} |
The models a provider serves. They lead its listing, so :model and the picker offer them; for a provider that serves no /models they are the whole of it. |
thinking |
default, off, low, medium, high, xhigh, max |
default |
Thinking level |
system_prompt |
string | Lug's prompt, with the workspace filled in | The system prompt. basic.lug adds the project's AGENTS.md and CLAUDE.md to it, and how @path references read. |
max_turns |
integer, 1 or more | 50 |
Model calls per run |
tool_concurrency |
integer, 1 or more | 10 |
How many of one model call's tool calls run at once. Their results go back in the order the model made them. |
approval |
off, policy, all |
policy |
Which tool calls ask first (see tools) |
lua_timeout |
integer, ms | 1000 |
The longest Lua may run without yielding before it is stopped |
leader |
key | <Space> |
What <leader> means in mappings |
timeout_len |
integer, ms | 500 |
How long a key sequence waits for its next key |
start_mode |
insert, normal |
insert |
The prompt's first mode |
split |
right, left, below, above |
right |
Where lug.ui.open puts a window when the plugin does not say |
clipboard |
boolean | true |
Copy yanks to the system clipboard (OSC 52) |
mouse |
boolean | true |
Capture the mouse, so the wheel arrives as the keys <ScrollUp> and <ScrollDown> |
background |
dark, light |
Detected at startup | Which palette auto picks |
editor |
a program and its arguments | $VISUAL, else $EDITOR |
What gf, :edit and the approval dialog's e open |
The stock runtime, basic.lug, declares ten more with lug.opt.declare. They exist once it
has loaded, so set them after require("haul").setup:
| Option | Type | Default | Meaning |
|---|---|---|---|
animate |
boolean | true |
The slug crawls along the prompt's top edge while the session works; off, it sits still |
auto_compact |
boolean | false |
Compact before a run once past 85% of the context window |
border |
rounded, square, double, thick or none |
rounded |
The box around the prompt, whose top edge also runs above each other window with its name; none draws neither |
dim_inactive |
integer, a percentage | 20 |
How far windows away from the focus fade towards the background; the transcript and the prompt count as one |
launcher |
top or bottom |
top |
Where the slug style's launcher bar sits, the : line's and the pickers' |
prompt_max_lines |
integer | 12 |
Rows the prompt grows to before it scrolls |
style |
slug or plain |
slug |
The feel: slug has the splash on a new session, the launcher for : and the pickers, the slug crawling while lug works, and the prompt boxed with the other windows named along a top edge; plain has none of them |
theme |
auto or a palette name |
auto |
The colour palette |
theme_overrides |
table shaped like a palette | {} |
Colours laid over whichever palette is in use |
tool_open |
list of tool names | { "edit" } |
Tool cards that open when their call finishes |
A plugin declares its own options the same way, and they work as Lug's do. Each accepted
assignment fires the option_changed event.
## Themes
The palettes are slug, Lug's own black, white and green, github-dark, github-light
and ansi. auto picks ansi when the terminal does not report truecolour, and otherwise
slug or github-light to match background. :theme opens a picker that previews as you move; :theme NAME sets one.
theme_overrides replaces colours before the highlight groups are derived from them, so
one change reaches every group built on it:
lug.opt.theme_overrides = { accent = "#ff8800" }
A theme sets the role groups, LugAccent, LugOk, LugWarn, LugError, LugMuted,
LugTitle and LugSelection, and links the rest of its groups to them, as plugins link
theirs (see lua-api), so changing a role changes everything
built on it.
:hi Group shows a highlight group, and :hi Group fg=#ff8800 bold or
:hi Group link=Other changes it until the next theme is applied. From Lua, use
lug.hl.set and lug.hl.get.
## Examples
-- ~/.config/lug/init.lua
lug.opt.model = "anthropic/claude-opus-5"
lug.opt.thinking = "high"
lug.opt.leader = ","
lug.keymap.set("normal", "<leader>q", ":q")
lug.cmd.set("hello", function(cmd)
lug.ui.notify("hello " .. (cmd.args[1] or "world"))
end, { desc = "say hello" })
lug.on("run_finished", function(event)
if event.outcome.status == "failed" then
lug.ui.notify("run failed: " .. event.outcome.error, "error")
end
end)
Every lug.* function and event is in lua-api.