macOS · Linux

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/uji

01

This is a complete config.

~/.config/uji/init.lualua
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)
~/code/uji
 Summarise the commits since yesterday.
Shell(git log --since=yesterday --oneline)
180810d fix: restore row trimming for the grid (#43)
320114d feat: allow user to configure greeting component (#42)
46311dc fix(input): add padding by default on input bar (#41)
04d782e fix: improve toolcall view and builtin toolcalls (#40)
ac74355 feat: show syntax highlighted diff on edit_file and read_file toolcall (#39)
bf01ee5 feat: add nix module (#38)
ce09323 feat: add backround jobs support (#37)

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.

~/.config/uji/lua/uji/core/prompt.lualua
local M = {}

function M.system(env)
  return "You are a careful coding agent. Work in "
    .. env.directory .. "."
end

return M

Or all of it.

UJI_RUNTIME=~/tree uji. The tree needs one module: uji.boot.

~/.config/uji/lua/uji/core/prompt.luauji.core.prompt
  • core/The engine.
  • builtin/What ships, written as plugins are.

Abridged.

03

The built-ins use the same API you do.

~/.config/uji/plugin/tools.lualua
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,
})
~/.config/uji/plugin/approval.lualua
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 })
~/.config/uji/plugin/litellm.lualua
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 },
  },
})
~/.config/uji/plugin/ask.lualua
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
ito on GitHub
~/.config/uji/plugin/clock.lualua
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

ScenarioMedian per frame
Key with 500 messages on screen416 µs
Stream a token over 500 messages596 µs
Wheel through 500 messages749 µs
Key into a 5,000 character draft1.69 ms
Scroll to the top of 500 messages1.78 ms
Paste 100 KB5.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
  • Google
  • 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.

shellsh
uji run --json --tools read_file "What does src/main.rs do?" \
  | jq -r 'select(.type == "done") | .text'

--json

  1. session
  2. message
  3. progress
  4. notice
  5. done

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_search and web_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/uji