A coding agent you can shape with Lua.
Everything it does, from startup to the screen, is Lua you can replace.
brew install uji-labs/uji/ujicargo install --git https://github.com/uji-labs/uji --locked uji01
This is a complete config.
require("uji.builtin.defaults")
uji.pack.add({ "uji-labs/uji-plugins" })
require("statusline").setup({})
require("planmode").setup({})
uji.tool.policy({
run_command = { allow = { "git status", "cargo test*" } },
})
uji.keymap.add("normal", "<C-p>", { command = "models" })
uji.command.add("standup", function()
uji.session.submit("Summarise the commits since yesterday.")
end)Seven commits since yesterday. Background jobs landed (#37), with a Nix module (#38) and syntax-highlighted diffs on edit_file and read_file (#39). The greeting is now a view you can replace (#42). The other three fix the tool call view, the input bar’s padding and row trimming in the grid.
02
Every part is a file you can replace.
In your config or in a pack. Remove yours and the built-in one returns.
local M = {}
function M.system(env)
return "You are a careful coding agent. Work in "
.. env.directory .. "."
end
return MOr all of it.
UJI_RUNTIME=~/tree uji. The tree needs one module: uji.boot.
- core/The engine.
- builtin/What ships, written as plugins are.
Abridged.
03
The built-ins use the same API you do.
uji.tool.add("branch", {
description = "Name of the current git branch.",
parameters = { type = "object", properties = {} },
policy = "allow",
display = { label = "Branch" },
run = function()
return io.popen("git branch --show-current"):read("*l") or "not a git repository"
end,
})uji.on("before_tool", function(call)
if call.name ~= "run_command" then
return nil
end
local command = call.arguments.command or ""
if command:match("^git push") then
return { deny = "Pushing is my job. Tell me when the branch is ready." }
end
if command:match("curl") or command:match("wget") then
return { ask = "This command reaches the network. Run it?" }
end
end, { priority = 10 })uji.provider.add({
id = "litellm",
name = "LiteLLM",
api = uji.api.openai(),
base_url = os.getenv("LITELLM_BASE_URL") or "http://localhost:4000",
auth_env = { "LITELLM_API_KEY" },
models = {
{ id = "claude-sonnet-4-5", context = 1000000, output = 64000, reasoning = true },
{ id = "glm-4.6", context = 204800, output = 131072, reasoning = true },
},
})uji.command.add("ask", function(args)
uji.session.submit("Investigate before changing anything. " .. args)
end)
uji.keymap.add("normal", "<A-a>", { command = "ask" })04
The screen is views, not escape codes.
ito, uji’s terminal UI kit. Your plugins and themes use it too.
- Views
- VStack, HStack, Grid, ZStack, Text, Lines, Fold, Spacer
- Controls
- Text field, list, scroll view, spinner
- Hosts
- Lua 5.1 to 5.5, or LuaJIT
- Licence
- MIT, on its own
local ito = require("ito")
local Clock = ito.view(function()
local shown = ito.state(os.date("%H:%M"))
return ito.HStack({
ito.Text("uji"):bold(),
ito.Spacer(),
ito.Text(shown.value):foreground(ito.Color.cyan),
}):on_tap(function()
shown.value = os.date("%H:%M:%S")
end)
end)
uji.ui.toolbar({
ito.ToolbarItem(ito.ToolbarPlacement.top_bar_leading, Clock),
})05
Lua on top. Rust underneath.
- to start and quit
- 45ms
- to open a 500 message session
- 10ms
- one binary
- 13MB
- lines of Lua
- 15k
Time to draw a frame
| Scenario | Median per frame |
|---|---|
| Key with 500 messages on screen | 416 µs |
| Stream a token over 500 messages | 596 µs |
| Wheel through 500 messages | 749 µs |
| Key into a 5,000 character draft | 1.69 ms |
| Scroll to the top of 500 messages | 1.78 ms |
| Paste 100 KB | 5.13 ms |
bench/, v0.2.1, release build. Median of three runs, 4-core 2.1 GHz Xeon, Linux. CI flags any pull request 20% slower.
In the kernel
- Scripting
- LuaJIT
- Async
- tokio
- Screen
- ratatui, crossterm
- Sessions
- SQLite
- Highlighting
- syntect
- Fuzzy matching
- nucleo
- Markdown
- pulldown-cmark
- Diffs
- similar
- Secrets
- system keychain
- Denied
- unsafe, unwrap, expect, panic!
06
Any model, with or without the screen.
Anthropic and OpenAI also take your subscription. Custom takes any OpenAI-compatible server. Keys stay in a file only you can read, or in the keychain.
/login
- Anthropic
- Baseten
- Cerebras
- Custom
- DeepSeek
- Fireworks
- GitHub Models
- Groq
- Hugging Face
- LM Studio
- Mistral
- Moonshot AI
- NVIDIA
- Ollama
- OpenAI
- OpenCode Go
- OpenCode Zen
- OpenRouter
- Perplexity
- Together
- xAI
- Zhipu AI
For scripts, CI and subagents. Same config, same plugins.
uji run --json --tools read_file "What does src/main.rs do?" \
| jq -r 'select(.type == "done") | .text'--json
sessionmessageprogressnoticedone
07
Small by default. Add what you use.
uji.pack.add({ "uji-labs/uji-plugins" })
statuslineDirectory, model, effort, context, tokens, cache hits.require("statusline").setup({})planmodeRead-only planning. You accept the plan.require("planmode").setup({})mcpMCP servers, over stdio or HTTP.require("mcp").setup({})telescopeA fuzzy finder.require("telescope").setup({})skillsAgent Skills folders.require("skills").setup({})websearchweb_searchandweb_fetch. No key.require("websearch").setup({})subagentTasks for agents, each its own uji process.require("subagent").setup({})readonlyDirectories the model can read but not edit.require("readonly").setup({})
Install it. Shape it later.
brew install uji-labs/uji/ujicargo install --git https://github.com/uji-labs/uji --locked uji