# Overview > What Luxe is, and where to find what you need. Luxe is a coding agent for your terminal. It works in your project with the model you choose, then walks you through what it changed so you can review it and open a pull request without leaving the terminal. New to Luxe? [Install](install.md) it, then follow the [Quickstart](quickstart.md). The rest of the guide is reference, for when you need it. ## Find what you need | To | Read | | --- | --- | | Connect a model, or switch between them | [Models and providers](providers.md) | | Find your way around the screen | [The interface](interface.md) | | Find a command or a key, or rebind one | [Commands and keys](commands.md) | | Point the agent at a file, issue or skill | [Mentions](mentions.md) | | Review what the agent changed | [Guided Review](guided-review.md) | | Work from an issue, open or merge a pull request | [Git and GitHub](github.md) | | Resume a session, or see what fills the context | [Sessions and context](sessions.md) | | See what the agent can use | [Tools](tools.md) | | Let the agent work through a plan on its own | [Autonomous runs](loop.md) | | Change the theme, keys or statusline | [Configuration](configuration.md) | | Give the agent your project's conventions | [Project context](project-context.md) | | Add tools, skills or your own commands | [Extending Luxe](extending.md), [Recipes](recipes.md) | | Control what the agent may do | [Security and permissions](security.md) | | Look up a command, flag or environment variable | [Command line](cli.md), [Environment variables](environment.md) | | Fix something that is not working | [Troubleshooting](troubleshooting.md) | ## Help - **Help** (`/help`) lists every key for the screen you are on, with any you have rebound. - `luxe doctor` checks which language servers, debuggers and tools Luxe can use on your machine. - [Troubleshooting](troubleshooting.md) covers common problems and how to report a bug. - Problems and questions: [GitHub issues](https://github.com/evanwhittaker/luxe/issues). ## Ask Luxe This guide is built into Luxe, for the version you have installed. Ask the agent how to do something in Luxe, such as *how do I add an MCP server?*, and it reads the answer from the guide rather than guessing. You can read it yourself too: ```bash luxe docs # every page luxe docs hooks # one page luxe docs --search "on_ask" ``` ## For agents Every page is also plain markdown, at the same address with `.md` on the end: `https://luxe.dev/docs/install.md`. [`llms.txt`](https://luxe.dev/llms.txt) lists every page with a one-line summary, and [`llms-full.txt`](https://luxe.dev/llms-full.txt) is the whole guide in one file. Inside Luxe, `luxe docs` serves the same pages offline. --- # Install > Install Luxe, check your setup, and pick a font. ## Install with Cargo ```bash cargo install luxe-cli luxe --version ``` This installs the `luxe` command. You need a recent stable Rust; [rustup](https://rustup.rs) installs one. To update, run the same command with `--force`. Luxe checks once a day for a newer release and says so on the start screen; see [Update notice](configuration.md#update-notice) to turn that off. ## Build from source ```bash git clone https://github.com/evanwhittaker/luxe.git cd luxe cargo build --release ./target/release/luxe --version ``` ## Requirements Luxe runs natively on Linux, macOS and Windows. Only a terminal and a model are required. | | | | --- | --- | | **Terminal** | Any current terminal with UTF-8. On Windows, use Windows Terminal. | | **Model** | An API key, a ChatGPT, Copilot or OpenRouter subscription, or a local server such as LM Studio or Ollama. See [Providers](providers.md). | | **Git** | For reviewing changes and working in worktrees. Without a repository, Luxe runs with nothing to review. | | **GitHub CLI** | Optional. `gh`, signed in, for issues and pull requests. | | **A Nerd Font** | Optional, for icons. See [Fonts](#fonts). | Luxe also uses these when they are installed: - `ast-grep`, for structural search. - A language server per language, for code intelligence. See [Code intelligence](tools.md#code-intelligence). - A debug adapter per language, for the debugger. See [Debugger](tools.md#debugger). ## Check your setup ```bash luxe doctor ``` `luxe doctor` lists the tools, language servers and debug adapters Luxe found, which ones work, and how to install what is missing: ``` External programs ok git git ok gh git (pull requests) Language servers ok rust rust-analyzer none go tried: gopls go install golang.org/x/tools/gopls@latest ``` ## Fonts Luxe draws its icons from a [Nerd Font](https://www.nerdfonts.com/) when your terminal has one. Without one, it falls back to plain characters on its own, and every icon has a word beside it, so nothing becomes unreadable. The screenshots use **JetBrains Mono Nerd Font Mono**: ```bash brew install --cask font-jetbrains-mono-nerd-font # macOS sudo pacman -S ttf-jetbrains-mono-nerd # Arch scoop install nerd-fonts/JetBrainsMono-NF-Mono # Windows, after `scoop bucket add nerd-fonts` ``` Use the **Mono** build of a Nerd Font. The other build draws icons wider than one cell, so they overlap the character after them. Set it in your terminal by its short family name: ```toml title="alacritty.toml" [font] normal = { family = "JetBrainsMono NFM" } ``` The short name matters on Linux desktops that pick their monospace font with a fontconfig rule, such as Omarchy. Asking for the full name, `JetBrainsMono Nerd Font Mono`, can hand you the desktop's font instead. To check what a name resolves to: ```bash fc-match -f '%{file}\n' "JetBrainsMono NFM" ``` JetBrains Mono lacks six symbols Luxe draws (`ℹ︎ ↳︎ ↺︎ ⏸︎ ✔︎ ✖︎`), and your system fills them in from another font. For every character from one face, use **IosevkaTerm** or **Adwaita Mono**: ```bash brew install --cask font-iosevka-term-nerd-font # "IosevkaTerm NFM" brew install --cask font-adwaita-mono-nerd-font # "AdwaitaMono Nerd Font Mono" sudo pacman -S ttf-iosevka-nerd ttf-adwaitamono-nerd scoop install nerd-fonts/IosevkaTerm-NF-Mono nerd-fonts/AdwaitaMono-NF-Mono ``` To use the wider icons anyway, set `"glyphs": "nerd_wide"` in [`config.json`](configuration.md), and Luxe gives each icon two cells. ## On Windows Install [Git for Windows](https://gitforwindows.org/). Luxe finds its `bash` even though it is not on `PATH`, and the agent needs a POSIX shell for most commands. Without one, commands run through `cmd.exe`, which handles `cargo test` or `npm run build` but not much else. If WSL is installed, Luxe skips the `bash.exe` in `System32`, which would run commands inside Linux against a different filesystem. ## Where Luxe keeps files | | Linux and macOS | Windows | | --- | --- | --- | | Configuration and credentials | `~/.config/luxe` | `%APPDATA%\luxe` | | State | `~/.local/state/luxe` | `%LOCALAPPDATA%\luxe` | | Cache | `~/.cache/luxe` | `%LOCALAPPDATA%\luxe` | An `XDG_CONFIG_HOME`, `XDG_STATE_HOME` or `XDG_CACHE_HOME` you have set wins on every platform. `LUXE_CONFIG_DIR` moves every configuration file, API key and provider sign-in. `LUXE_STATE_DIR` moves the state Luxe keeps for you: the model you last chose, MCP sign-ins, and the logs of commands that only report, such as `luxe auth`. Inside a project, Luxe keeps its sessions, logs and caches in `.luxe/`, with a `.gitignore` that keeps all of it out of Git except the files a team may want to share: `permissions.json`, `mcp.json`, `hooks.json` and `skills/`. --- # Quickstart > From first launch to a reviewed pull request. This walks through one change from start to finish: connect a model, ask for something, review what the agent did, and open a pull request. ## 1. Start Luxe in your project ```bash cd your-project git switch -c category-enum # a branch for the work, so it can become a pull request luxe ``` Reviewing changes and opening a pull request need a Git repository. A pull request also needs a branch other than the one it merges into; `luxe --worktree` makes one for you, in a [worktree](github.md#work-in-a-worktree) of its own. The first time, Luxe asks four questions: a theme, a provider, which tools the agent can use, and what Luxe opens on. Each one previews or explains itself, and `enter` moves on. `s` skips setup and keeps none of the answers, though a provider you signed in to stays signed in. Your answers go to `~/.config/luxe/config.json`, and `/settings` or `luxe init` changes them later. ![Setup, on its theme step](images/quickstart-setup.svg) After that, Luxe opens on the start screen: a prompt, with your last session, your other sessions and **Configuration** listed under it, or **Set up a provider** first if you have none yet. Type to ask something or to find a session or a command, or use `↑`/`↓` and `enter` to open a row. ![The start screen](images/quickstart-dashboard.svg) ## 2. Connect a model If you skipped the provider question, the start screen leads with **Set up a provider**. Open it and pick one: | Option | For | | --- | --- | | **Sign in to a subscription** | ChatGPT Plus or Pro, GitHub Copilot, OpenRouter | | **Add an API key** | OpenAI, Anthropic, Google, Groq and others | | **Connect a local or custom model** | LM Studio, Ollama, Azure, any OpenAI-compatible endpoint | ![Configuration, before a provider is set up](images/quickstart-configuration.svg) When it connects, the model picker opens. Choose a model, and change it any time with `/model`. An API key in your environment works too. Luxe finds `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY` and the rest, and offers that provider's models. To sign in from a script or a machine without the interface, use the CLI: ```bash luxe auth login # choose a provider from a list luxe auth login anthropic --stdin < key.txt luxe auth list # every provider and how it is configured ``` Credentials are stored in `~/.config/luxe/auth.json`, readable only by you. See [Providers](providers.md) for every option. ## 3. Ask for something Type what you want on the start screen and press `enter`: it starts a new session and sends it. `shift+enter` adds a line. ``` a notification's category is a free-form string, so a typo compiles; make it an enum ``` The agent reads, edits and runs commands, and each step appears as a card. Click a card to see its full output, or press `ctrl+o` for every card's; `ctrl+esc` stops the turn. You can keep typing while it works: a message sent mid-turn reaches the agent between steps, without stopping it. ![A turn in progress](images/quickstart-turn.svg) **Changes** (`ctrl+p`) shows the diff so far, beside the conversation on a wide terminal and in its place on a narrow one. ## 4. Review the change Press `ctrl+g` to start a **Guided Review**. Luxe writes a guide to the change, split into chapters, each with what changed, why, and how it was checked, next to the code. When `review ready` shows above the prompt, press `ctrl+g` again to open it. ![Guided Review](images/quickstart-review.svg) | Key | Does | | --- | --- | | `space` | Next | | `c` | Comment on the line under the cursor | | `a` | Ask the agent about it | | `alt+enter` | Finish the review | | `?` | Every key in the review | Comments go to the agent as work to do when you finish with **Request fixes**. ## 5. Open a pull request This needs the GitHub CLI, `gh`, signed in (`gh auth login`): **Open pull request** is offered once `gh` says the branch has none. Finish the review (`alt+enter`) and choose it. Luxe drafts the title and description from the review. The heading says what happens before it opens, such as "commits and pushes first". The pull request pictured came from a session that took up an issue ([`luxe --issue 123`](github.md#start-from-an-issue)), so its branch is named for the issue and its description closes it. ![Opening a pull request](images/quickstart-pull-request.svg) Press `t` or `e` to edit the title or description, `d` to open it as a draft, and `enter` to open it. ## 6. Come back later **Resume**, the first row on the start screen, picks up your last session. From the command line: ```bash luxe -c # the most recent session in this project luxe sessions list # this project's sessions, newest first luxe --resume # a specific one ``` ## Next - [The interface](interface.md): the dock, the statusline, the Command Palette and every command. - [Guided Review](guided-review.md): reviewing someone else's pull request, and answering review threads on yours. - [Configuration](configuration.md): themes, keys and the statusline. - [Project context](project-context.md): give the agent your project's conventions in `AGENTS.md`. --- # The interface > The start screen, the conversation, the composer, the dock, the statusline and the preview. ![The dock beside the preview, showing one file's diff](images/interface-peek.svg) This page names the keys for each part of the screen. All of them, and how to rebind them, are in [Commands and keys](commands.md); `/help` lists the ones for the screen you are on, and the **Command Palette** (`ctrl+k`) finds any command by name. Which editor and file manager open is in [Environment variables](environment.md#integrations). ## The start screen Luxe opens on a prompt. Type and press `enter` to ask in a new session, or choose a row from the list under it with `↑`/`↓` and `enter`. | Row | Opens | | --- | --- | | **Set up a provider** | Configuration. Leads the list, and takes `enter` on an empty prompt, until a provider is set up. | | **Resume** | Your last session, named with its title and how long ago. Once a provider is set up, `enter` alone opens it. | | **New session** | A fresh conversation | | **Sessions…** | Every session in this project, searchable. `/sessions` opens it too, here or in a session. | | **Review changes** | A [Guided Review](guided-review.md) of your uncommitted changes, when there are any | | **Configuration** | Providers, credentials, [settings](configuration.md#settings) and config files, once a provider is set up | | **Quit** | Leaves Luxe. `ctrl+c` does too, here and in a session, even mid-turn, unless a prompt or menu is open, which it answers instead. | | **RECENT** | Your other recent sessions | Typing narrows the list. The first row becomes **Ask in a new session**, and under it come the sessions whose titles match, the commands that do, and a search through every session's transcript. A session you open this way keeps what you typed in its composer. Once a draft reads as a prompt (more than five words or 48 characters, a second line, a pasted chip or a mention), the list folds away and `enter` asks. `/` and `@` open the same command and [mention](mentions.md) pickers a session has, under the prompt. A command chosen there either opens over the start screen, as `/model` and `/theme` do, or starts a new session to run in. ![The start screen with a search typed](images/interface-launcher-typing.svg) Every key is text here, so `esc` clears the prompt rather than quitting. `shift+tab`, or a click on the prompt's title, switches between build and [plan mode](agent.md#plan-then-build) before you start, and the session opens in the mode you chose. The statusline under it is the one the session will have. Nothing picks a model for you. Until you choose one, sending a message opens the model picker with your message kept. ### Styles ```json title="~/.config/luxe/config.json" { "dashboard": { "style": "minimal" } } ``` `minimal` shows the prompt alone, and the list waits for `↓`. `"dashboard": false` skips the start screen and opens a new session. **Start screen** in `/settings` sets the same key. ![The minimal start screen](images/interface-launcher-minimal.svg) Luxe works on the project it was started in. To switch projects, `cd` there and run `luxe`. ## The conversation Each tool call the agent makes is a card, showing what it did and how long it took. `ctrl+o` shows every card's full output, and clicking a card opens it. ![A finished turn: three reads folded into one card, an edit with its diff, a test run and the reply](images/interface-turn.svg) A shell command card is titled by what the agent says the command is for, such as "Run the forms tests", with the first line of the command under it and the rest a click away. A test run through the shell that fails shows its counts and what failed, the same as the `test` tool's card. Three or more lookups from one message (`read`, `grep`, `glob`, `ast_grep` and the code intelligence tools) fold into one card that lists what they looked at: the first five by name, or four and a count of the rest. So do three or more calls in a row to one MCP server, with nothing shown between them. Click a folded card to open it into one card per call, and click again to fold it. Edits, commands, `git`, the todo list and anything that failed always keep their own card. `"conversation": { "tool_runs": "cards" }` turns folding off. Tables in a reply fit the width they are drawn at. Narrower than the table, cells wrap inside their columns; too narrow for that, each row is listed as `Header: value` lines. Code blocks in a reply are highlighted for their language. Click the rule above a block to copy it. | To | Key | | --- | --- | | Scroll | wheel, `ctrl+↑` / `ctrl+↓`, `pageup` / `pagedown` | | Jump to the newest output | `ctrl+end` | | Search the conversation | `ctrl+f` | | Select the previous or next message | `alt+k` / `alt+j` | | Copy the selected message | `alt+y` | | Edit your last message and send it again | `alt+e` | | Ask again, after the last answer | `alt+t` | | Replace the last answer | `alt+g` | | View the newest image full-window | `alt+i` | ## The composer `enter` sends. `shift+enter` adds a line. Type `/` for [commands](commands.md) and `@` to [mention](mentions.md) a file, skill, issue or other context. The composer's title names the session's mode, **build** or **plan**. `shift+tab` or a click on the title switches it; see [Plan, then build](agent.md#plan-then-build). While the agent works, you can keep typing: | Key | Does | | --- | --- | | `enter` | **Steer**: the message reaches the running turn between steps, without stopping it | | `alt+enter` or `alt+q` | **Queue**: the message becomes the next turn, after this one ends | | `alt+↑` | Bring waiting messages back into the composer to edit | | `ctrl+esc` | Stop the turn. Waiting messages come back into the composer. | A waiting message sits above the composer, marked `steering` or `queued`, until it is delivered. `ctrl+v` or `shift+insert` pastes. Pasted text of three lines or more, or over 150 bytes, becomes a `[Pasted 40 lines]` chip, and a pasted image becomes `[Pasted Image]`. Both are sent in full. A chip, like a mention, is deleted with one `backspace`. The composer and every other text field take the usual line-editing keys: | To | Key | | --- | --- | | Move by word | `ctrl+←` / `ctrl+→`, or `alt+←` / `alt+→` | | Start or end of line | `home` / `end`, or `ctrl+a` / `ctrl+e` | | Delete the word before or after | `alt+backspace` / `alt+delete` | | Delete to the start of the line | `ctrl+u` | | Select | `shift` with any move, or `alt+a` for everything | On macOS, Option and Cmd with the arrows and `backspace` do the same in Ghostty, and in other terminals with their natural text editing preset. ## The dock The Context Dock is the column beside the conversation. It sits beside it on terminals 136 columns or wider. On narrower ones it is hidden until you press `alt+d`, and then opens over the conversation, or in its place below 80 columns. | Section | Shows | | --- | --- | | **PROJECT** | The project, its branch, and the branch's pull request: `#138 · 1 failing · 2 threads` | | **PLAN** | The agent's todo list, scrolled to what it is working on | | **FILES** | The project as a tree, with what changed marked in it. Click a file to [preview](#preview) it. | | **ELSEWHERE** | Files the session changed outside the project, when there are any | | **MODEL** | The provider, model, thinking level and [mode](agent.md#plan-then-build). Click the model to change it, or the mode to switch it. | | **SESSION** | Context used, tokens and cost, with what a review and compaction cost on rows of their own. A review's row shows its full cost once its guide or drafted summary is done. | `alt+d` shows or hides the dock, and `/dock left` or `/dock right` moves it. To set it for good: ```json title="~/.config/luxe/config.json" { "dock": { "mode": "auto", "position": "left" } } ``` `mode` is `auto` (beside the conversation when there is room), `shown` or `hidden`. Drag the line between the dock and the conversation to make the dock wider or narrower. It stays that width the next time Luxe starts; double-click the line to put it back. ### Files **FILES** is the project's tree. Dotfiles such as `.github` are in it; what `.gitignore` ignores is not, until you ask for it. Folders on the way to a changed file open on their own, so the change is in view; a changed file shows its letter (`M`, `A`, `D`, `?`) and its line counts, and a closed folder with a change inside shows a dot. A folder holding only one folder is drawn as one row, `crates/luxe-core/src`. `all ▾` on the heading opens a menu: show only what changed, or show the ignored files too, drawn faint. Click a folder to open or close it, and a file to [preview](#preview) it; the file showing is drawn in the accent color. Right-click either for everything you can do with it. The tree only reads. To move, rename or delete files, ask the agent, which goes through your permissions and shows up in the review, or press `O` to open the folder in your file manager. A terminal one such as yazi takes the screen until you quit it, as your editor does; the tree reads its folders again when you come back. `ctrl+t` moves the keyboard into the tree, and pressed again, into the file showing beside it. `tab` trades the keyboard between the two, and `esc` hands it back to the composer. | In the tree | Key | | --- | --- | | Move | `j` / `k`, or the arrows | | Find a file by typing part of its path | `/` | | Open a folder, or show a file | `enter` | | Open a folder, or show a file and move into it | `l` | | Close a folder, or go to its parent | `h` | | Open in your editor | `o` | | Open its folder in a file manager | `O` | | Mention it in the composer | `@` | | Copy its path | `y` | | Only what changed, or everything | `a` | | Show the ignored files, or hide them | `i` | | Read the folders again | `R` | | Back to the composer | `esc` | Any other key goes to the composer, so you can start typing without leaving the tree first. `/` filters the tree as you type. It searches the whole project, not just the open folders, and matches the letters in order anywhere in the path, so `tuiview` finds `crates/luxe-tui/src/view.rs`. The tree shows the best matches under the folders that hold them, with the matched letters lit. The arrows move through them, and `enter` shows one. `esc` clears the filter and leaves the cursor on the file you found. Files `.gitignore` ignores are never matched, even while the tree is showing them. ## The statusline The statusline is the bar under the composer. It carries the same model, context and cost the dock does, so by default it shows only when the dock is not beside the conversation. That makes `alt+d` a swap on a wide terminal: hiding the dock brings the statusline back, and showing it puts the statusline away. `alt+s` or `/statusline show`, `hide` or `auto` changes that, and `"statusline": { "visibility": "shown" }` keeps it. [Statusline modules](appearance.md#statusline) sets what it shows. ## Preview The **preview** (`ctrl+p`) shows one file in place of the conversation, with the dock and the composer where they were, so you can reply about what you see. A changed file opens on its diff, and `alt+m`, or **FILE** in the header, shows it whole; **DIFF** goes back. Any other file opens whole. Click another file under `FILES` to switch; click the open one, or press `esc`, to go back. `/preview src/app.rs` opens a given file. To ask about part of a file, drag across its lines (or click one) and choose **ask about lines** under them, or press `alt+l`. The composer gets `@[file:src/app.rs:40-52]`: the agent receives the path and the lines, and reads them itself. Right-click the lines to copy them instead. On a wide terminal the conversation stays beside the diff. The diff takes the width code needs, about 134 columns in one column or 218 side by side, and the conversation gets the rest. Drag the line between them to change the split, and double-click it to go back. The preview is not a review: looking at a file does not count it as reviewed. | In the preview | Key | | --- | --- | | Next or previous changed file | `alt+n` / `alt+p` | | The whole file, or its diff | `alt+m` | | Ask about the lines you picked | `alt+l` | | Side by side, or one column | `ctrl+b` | | Wrap long lines, or not | `alt+w` | | Open the file in your editor, at the top line shown | `alt+o` | | Open a [Guided Review](guided-review.md) of your changes, at this file | `ctrl+g` | | Back to the conversation | `esc` | With the keyboard in the file (`ctrl+t` twice, or `tab` from the tree), a cursor moves line by line and the file scrolls to keep it in view. In a diff the same keys scroll. | In the file | Key | | --- | --- | | Move the cursor | `j` / `k`, or the arrows; `space` / `pageup` a page, `ctrl+d` / `ctrl+u` half of one; `g` / `G` the ends | | Select lines, like vim's visual-line mode: moves grow the pick, `o` moves its other end | `v` or `V` | | Grow a pick by one line without the mode | `J` / `K`, or `shift` with the arrows | | Ask about the pick, or the cursor's line | `enter` | | Copy the pick, or the cursor's line | `y` | | Open in your editor at the cursor | `o` | | Back to the tree | `tab` or `h` | | Drop the pick or leave visual mode, then back to the composer | `esc` | ## Mouse and clipboard The wheel scrolls whatever is under the pointer, and a click acts on what you click. Drag any scrollbar to scroll, or press anywhere on it to jump. The lines between panes move: the dock's, the preview's, and the one beside a review's guide. A line lights under the pointer; drag it to resize the pane beside it, or double-click it to put the pane back to its own width. Luxe remembers the widths for next time. Drag across the conversation or the composer to select, and the selection is copied when you let go. Click a message to copy all of it. `ctrl+insert` or `ctrl+shift+c` copies a selection too. Your terminal's own copy key reaches Luxe only if the terminal passes it through. Either hold `shift` (`option` on macOS) while dragging, to make a selection the terminal copies itself, or let the copy key fall through when the terminal has nothing selected. In Ghostty: ```ini title="ghostty config" keybind = performable:control+insert=copy_to_clipboard ``` Luxe copies to the desktop clipboard, and over SSH through the terminal (OSC 52). Inside tmux, that needs `set -g set-clipboard on`. `/mouse off` hands the mouse back to the terminal for the session, and `"mouse": false` in `config.json` starts with it off. `"conversation": { "scroll_lines": 5 }` sets how far a wheel notch scrolls, from 1 to 20 lines (3 by default). --- # Commands and keys > The Command Palette, slash commands, Help, every default key, and how to rebind them. ## Command Palette and slash commands Every command is in two places: - **Command Palette** (`ctrl+k`) opens over whatever you are doing, anywhere, with your draft left where it was. - **Slash commands**: type `/` at the start of an empty composer and the same commands open above it. ![The Command Palette, with the commands used last at the top](images/commands-palette.svg) Both list only what can run where you are, with the five you used last at the top. Type to filter by name, slash command or a few words each is known by: `exit` finds **Quit**. A pane beside the list says what the command does and the arguments it takes. `enter` runs a command. In the slash list, `tab` puts it in the composer so you can add arguments: `/diff pr 142`, `/dock left`, `/compact the API decisions`. Past the command, the list shows the arguments it takes, such as `/diff turn` or `/diff pr `, and narrows as you type. `tab` fills in the one under the cursor. `enter` runs it when nothing is missing, and fills it in when it still needs a value. `esc` puts the list away for that command. In the composer, a command Luxe knows is highlighted, and so are the words of it you have typed, like `pr` in `/diff pr 142`. A command that stays plain is misspelled. ## Slash commands | Command | Does | | --- | --- | | `/help` | Every key for this screen, as you have bound them | | `/model` | Change the model; the session carries on | | `/thinking` | How hard the model reasons, from Off to Ultra | | `/mode` | Switch between build and [plan mode](agent.md#plan-then-build): `/mode`, `/mode plan`, `/mode build` (`shift+tab`) | | `/review` | A [second opinion](agent.md#a-second-opinion) on the uncommitted changes: `/review`, `/review error handling` | | `/new` | Start a new session. The current one is kept. | | `/sessions` | Find another session in this project and pick it up; see [Sessions](sessions.md) | | `/context` | What fills the context window, largest first | | `/compact` | Summarize the conversation now. Add what to keep: `/compact the API decisions`. | | `/cost` | What this session has cost, by model and kind of token | | `/search` | Find text in the conversation | | `/diff` | Open a [Guided Review](guided-review.md): `/diff`, `/diff turn`, `/diff base main`, `/diff commit `, `/diff pr 142` | | `/preview` | [Preview](interface.md#preview) a file, whole or as its diff: `/preview`, `/preview src/app.rs` | | `/editor` | Open the project in your editor, or a file: `/editor`, `/editor src/app.rs`, `/editor src/app.rs:42` | | `/pr` | This branch's pull request: status, reviewers and conversation | | `/github` | Pull requests and issues on GitHub | | `/stack` | Restack a stack of pull requests | | `/worktree` | Where this session works. `/worktree remove` ends it and removes its worktree. | | `/dock` | Show or hide the dock: `show`, `hide`, `auto`, `left`, `right` | | `/statusline` | Show or hide the statusline: `show`, `hide`, `auto` | | `/theme` | Preview and choose a theme | | `/mouse` | Mouse capture `on` or `off` | | `/images` | What this terminal can draw, and why | | `/permissions` | Approve every tool call that would ask you: `on`, `off`. Deny rules still apply. | | `/tools` | Every tool the agent can use this run, and the ones it cannot | | `/lsp` | Language servers: which started, and what they are doing | | `/debug` | Debug adapters, and whether each can be reached | | `/mcp` | MCP servers, and the tools each offers | | `/config` | Providers, credentials, settings and config files | | `/settings` | [Settings](configuration.md#settings) as controls: appearance, the conversation, the dock, tools and the agent | | `/login`, `/logout` | Add or remove a provider's credential | | `/auth` | Which providers you are signed in to, and how | | `/reload` | Re-read your config and keys without restarting | | `/quit` | Leave Luxe. The session is already saved. | The Command Palette also lists commands with no slash form, such as **Copy message**, **Swap the sidebar's side** and, in a review, **Send comments**. ## Help **Help** (`/help`) shows every key for the screen you are on, grouped and drawn from your own bindings. In Guided Review it shows the review's keys. Help has no default key, because the obvious ones do not reach a terminal program reliably. To give it one: ```json title="~/.config/luxe/keybindings.json" { "tui.app.toggle_help": ["f1"] } ``` ## Default keys The name in the last column is what `keybindings.json` uses. **Compose** | Key | Does | Name | | --- | --- | --- | | `enter` | Send, or steer a running turn | | | `shift+enter` | New line | `tui.editor.new_line` | | `alt+q` | Queue the message for the next turn | `tui.editor.queue_message` | | `alt+↑` | Bring waiting messages back | `tui.editor.dequeue_message` | | `ctrl+esc` | Stop the turn | `tui.editor.cancel` | | `ctrl+v`, `shift+insert` | Paste | `tui.editor.paste` | | `alt+a` | Select the whole draft | `tui.editor.select_all` | `enter` and `alt+enter` are fixed. `ctrl+enter` also sends, as `tui.editor.submit`. **Read** | Key | Does | Name | | --- | --- | --- | | `ctrl+↑`, `ctrl+↓` | Scroll | `tui.convo.scroll_up`, `tui.convo.scroll_down` | | `pageup`, `pagedown` | Scroll a page | `tui.convo.page_up`, `tui.convo.page_down` | | `ctrl+f` | Search the conversation | `tui.convo.search` | | `alt+k`, `alt+j` | Select the previous or next message | `tui.convo.select_block_up`, `tui.convo.select_block_down` | | `ctrl+o` | Full tool output | `tui.convo.toggle_tool_output` | | `alt+i` | View image | `tui.convo.open_image` | | `alt+y` | Copy message | `tui.convo.copy_message` | | `ctrl+insert`, `ctrl+shift+c` | Copy selection | `tui.convo.copy_selection` | **Conversation** | Key | Does | Name | | --- | --- | --- | | `alt+e` | Edit and resend your last message | `tui.convo.edit_resend` | | `alt+t` | Ask again | `tui.convo.retry` | | `alt+g` | Replace the last answer | `tui.convo.regenerate` | An edit replaces your last message and the answer to it, and **Replace the last answer** drops the old answer, so the model sees only the new version. **Ask again** sends your last message once more, after the answer if there was one. The replaced messages stay in the session file but leave the conversation. All three wait for a running turn to finish. **Workspace** | Key | Does | Name | | --- | --- | --- | | `ctrl+k` | Command Palette | `tui.app.open_palette` | | `shift+tab` | Switch between build and plan mode, from the composer or the start screen | `tui.app.cycle_mode` | | `alt+d` | Show or hide the dock | `tui.workspace.toggle_dock` | | `alt+s` | Show or hide the statusline | `tui.statusline.toggle` | | `ctrl+p` | Preview | `tui.preview.toggle` | | `alt+n`, `alt+p` | Next or previous changed file, in the preview | `tui.preview.file_next`, `tui.preview.file_prev` | | `alt+w` | Wrap long lines, in the preview | `tui.preview.toggle_word_wrap` | | `alt+o` | Open the file in your editor, in the preview | `tui.preview.open_in_editor` | | `alt+m` | Show a changed file whole, or its diff, in the preview | `tui.preview.toggle_view` | | `alt+l` | Ask about the lines you picked, in the preview | `tui.preview.ask_about_selection` | | `ctrl+t` | Move the keyboard into the dock's file tree, then into the file showing | `tui.workspace.focus_files` | | `ctrl+g` | Guided Review | `tui.review.open` | | `ctrl+shift+r` | Reload config and keys | `tui.app.reload` | | `ctrl+c` | Quit | `tui.app.quit` | **Guided Review** | Key | Does | Name | | --- | --- | --- | | `c` | Comment on this line | `tui.review.add_finding` | | `a` | Ask the agent | `tui.review.ask` | | `alt+enter` | Finish review | `tui.review.finish` | | `alt+u` | Send comments | `tui.review.send` | | `alt+c` | All comments | `tui.review.findings` | | `e` | Edit the selected comment | `tui.review.edit_finding` | | `x` | Delete the selected unsent comment | `tui.review.delete_finding` | | `r` | Resolve or reopen the selected comment | `tui.review.resolve_finding` | | `alt+r` | Reanchor the selected stale comment | `tui.review.reanchor_finding` | | `]`, `[` | Next or previous chapter | `tui.review.chapter_next`, `tui.review.chapter_prev` | | `n`, `shift+n` | Next or previous hunk | `tui.review.hunk_next`, `tui.review.hunk_prev` | | `enter` | Open what the cursor is on | `tui.review.open_selection` | | `home` | Back to column zero | `tui.review.line_start` | | `g`, `alt+v` | Show or hide the guide | `tui.review.toggle_guide` | | `f` | Open or fold collapsed churn | `tui.review.toggle_noise` | | `ctrl+b` | Side by side, or one column | `tui.review.toggle_split` | | `w` | Wrap long lines | `tui.review.toggle_word_wrap` | | `s` | Change what is reviewed | `tui.review.source` | | `ctrl+r` | Refresh the review | `tui.review.refresh` | | `o` | Open on GitHub | `tui.review.open_in_browser` | | `shift+e` | Open the file in your editor, at the cursor's line | `tui.review.open_in_editor` | | `alt+←` | Back to the conversation | `tui.review.return_chat` | | `?` | Help | `tui.review.help` | ## Rebind keys Put your bindings in `~/.config/luxe/keybindings.json`, or open it from **Configuration** with **Edit configuration files**. Each entry is a name from the tables above and a list of keys: ```json title="~/.config/luxe/keybindings.json" { "tui.app.open_palette": ["ctrl+p"], "tui.preview.toggle": ["alt+p"], "tui.app.toggle_help": ["f1"] } ``` - A binding replaces that action's default keys rather than adding to them. - A key you bind is taken from any action that had it by default. - A key is modifiers and one key joined by `+`: `ctrl`, `shift`, `alt`, then a letter, a digit, `enter`, `esc`, `tab`, `space`, `backspace`, `delete`, `insert`, an arrow (`up`, `down`, `left`, `right`), `home`, `end`, `pageup`, `pagedown`, or `f1` to `f12`. - An unknown name or key is skipped with a warning in the log, and the rest still load. An action whose keys are all invalid is left with none, rather than its defaults. - A project's `.luxe/keybindings.json` applies over yours. `/reload` or `ctrl+shift+r` applies a change without restarting. ## Alt keys on a Mac A Mac has no Alt key. Its Option key types characters instead: `option+o` is `ø`, `option+p` is `π`. Most of Luxe's `alt` keys need your terminal to send Option as Alt: | Terminal | Setting | | --- | --- | | Ghostty | `macos-option-as-alt = true` (or `left`) | | iTerm2 | **Profiles › Keys › Left Option key: Esc+** | | Terminal | **Settings › Profiles › Keyboard › Use Option as Meta key** | | kitty | `macos_option_as_alt yes` | | WezTerm | Left Option already sends Alt; for the right one, `send_composed_key_when_right_alt_is_pressed = false` | | Alacritty | `option_as_alt = "Both"` under `[window]` | Ghostty already does this on the "U.S." keyboard layouts. On the others it sends the character and marks it as an Alt key. On "ABC", the Mac's default English layout, Luxe reads that back as the key you pressed, so `option+o` opens a file from the preview without any setting. On other layouts, set the option above. --- # Mentions > Point the agent at a file, skill, issue, MCP server or your own tracker with `@`. Type `@` at the start of a word in the composer to mention something. The picker opens on your project's files; `tab` and `shift+tab` move between its tabs, typing filters, `↑`/`↓` move, `enter` inserts the mention, and `esc` or a space closes it. ![The @ picker on its Files tab, changed files first](images/mentions-picker.svg) | Tab | Lists | | --- | --- | | **Files** | Changed files first, then the rest of the project, 50 rows at most; type to narrow them. Respects `.gitignore`, and never lists `.git`, `target`, `node_modules` or `.cache`. | | **Skills** | The [skills](skills.md) you have installed | | **GitHub** | The pull requests and issues `/github` lists, through `gh` | | **MCP** | The [MCP servers](mcp.md) this session can use, when there are any | | Your own | Any [mention source](mention-sources.md) you add: Jira, Asana, a runbook index | Dotfiles and files under dot-folders, such as `.gitignore` and `.github/`, are left off the first list but show up when you type. Start with `.` to rank them with everything else. The file list is read from disk each time the picker opens, every few seconds while the Files tab shows, and when the agent changes files, so a file made or moved a moment ago is there. `ctrl+r` reads it again on the spot. ## What the agent receives A mention is a pointer, not a copy. Nothing is pasted into your message: | Mention | The agent gets | | --- | --- | | `@[file:docs/plan.md]` | The path, to read when it needs to | | `@[skill:gh-stack]` | The skill's name, to load with the `skill` tool | | `@[file:src/app.rs:40-52]` | The path and those lines, from **ask about lines** in the [preview](interface.md#preview) | | `@[github:acme/todo/issues/42]` | Its number, title, state and address, and the `git` tool call that reads it in full | | `@[mcp:asana]` | The server to use, with the names of its tools the first time it is signed in and listing them | | `@[jira:PROJ-12]` | The text your mention source gave for that row | A mention typed by hand works too. The title and state of an issue, and a mention source's text, come from a row you picked this session; for one you typed, the agent gets the address or `Jira PROJ-12` and looks it up itself. A file or skill that does not exist is reported as missing. So a large file never floods the conversation, and a file the agent then edits is never in it twice in two versions. *Review @[file:docs/plan.md] and draft a plan* is enough: the agent opens the file itself. In the composer, a mention is one chip. The cursor steps over it, and one `backspace` deletes it. In `/github`, `c` inserts a mention of the pull request or issue under the cursor. --- # Guided Review > Read a change chapter by chapter, comment for the agent, and finish with a commit, fixes or a pull request. Guided Review reads a change back to you in the order it makes sense, split into chapters. Each chapter says what changed, why, and how it was checked, beside the code. Comments you leave go to the agent as work to do. ![A review: the guide on the left, the hunk being read on the right](images/review-guide.svg) ## Open a review Press `ctrl+g`, or run `/diff`. Luxe writes the guide first; when `review ready` shows above the composer, press `ctrl+g` again to open it. Asked for while a turn is running, the review waits for the turn to end. A review you already have opens at once, and one you had open when Luxe stopped comes back with your comments. | Review | Command | | --- | --- | | Uncommitted changes | `/diff` | | What the last turn changed | `/diff turn` | | Everything since a branch or commit | `/diff base main` | | One commit | `/diff commit 4c1e2a0` | | A pull request | `/diff pr 142`, `/diff pr #142`, or its address | Inside a review, `s` changes what it covers: uncommitted changes, the last turn, a base or a commit. A pull request takes `/diff pr`. If the agent committed its work, so nothing is uncommitted, `ctrl+g` and `/diff` review the last turn instead. Luxe remembers what you have already reviewed in each project, so the next review leads with what is new since then. ## Read it The guide on the left opens with why the change exists and what it does, then a block per chapter with its files. The right pane shows the hunk you are on, with the guide's sentence about it. Drag the line between them to give the guide more or less room; double-click it to go back. | To | Key | | --- | --- | | Next step. Past the last one, finish. | `space` or `n` | | Previous step | `shift+n` | | Next or previous chapter | `]` / `[` | | Next or previous file | `ctrl+↓` / `ctrl+↑` | | Scroll | `↑` / `↓`, `pageup` / `pagedown`, or half a page with `ctrl+d` / `ctrl+u` | | Hide the guide, or the hunk's sentence | `g` / `z` | | Side by side, or one column | `ctrl+b` | | Wrap long lines | `w` | | Search | `/` | | Every key in the review | `?` | | Back to the conversation | `esc`. The review stays pinned until you return. | ### What needs you A review lists the hunks that need your attention and accounts for the rest. A hunk covered by a test that passed says so on its own header and stays in the diff. Lockfiles, generated files, moves and formatting-only changes fold away, each run of them in a file into one row; `f` opens them all. Three things are flagged and stay on the list, whatever else passed. Each flagged file says so on its row and in the walkthrough, and the guide gives the total for any kind with more than two: - A line that looks like a credential (`sk-…`, `ghp_…`, `AKIA…`, a `password = "…"`) - A weakened check: a deleted test, an added `#[ignore]`, an edit to CI or `deny.toml` - A file the agent edited without reading first If a test fixture needs a value that looks like a credential, mark that line with `// luxe:allow-secret`, `gitleaks:allow`, `pragma: allowlist secret` or `nosec`. To leave project bookkeeping out of every review: ```json title=".luxe/config.json" { "review": { "exclude": ["PROGRESS.md", "docs/design/**"] } } ``` The patterns take `*`, `**`, `?` and a trailing `/`, and a project's list replaces your own. Excluded files are counted in the guide. Luxe's own `.luxe/` is never reviewed. A review leaves out a file it cannot hold, so the rest is still reviewable: in the last turn, any file over 1 MiB, unreadable, or not a regular file; in uncommitted changes or a base, a new file that is not ignored and is over 1 MiB or not a regular file. The top of the guide names each one, so a review that shows less than the change never passes for all of it. ## Comment and ask | Key | Does | | --- | --- | | `c` | **Comment** on the line under the cursor. It waits for you to send it: to the agent, or to GitHub with your review of a pull request. | | `a` | **Ask the agent** about the line. It answers right away and changes nothing. | | `alt+c` | **Feedback**: every comment, with the one under the cursor in full | | `alt+u` | **Send** the unsent comments to the agent, from a preview where `space` leaves one out | In **Feedback**, `enter` goes to a comment, `c` replies, `e` edits, `x` deletes one not yet sent, `r` marks it verified, resolved or reopened, `d` shows its details, `alt+r` moves a stale one to where its line went, `v` shows what changed at its line, and `u` sends. On an answered question, `c` turns it into a comment, so the question, the answer and your direction reach the agent together. You can keep commenting while the agent works on what you sent. When the agent's fix is in, the review says `The fix is in · ctrl+r refresh`. `ctrl+r`, or `/diff refresh`, reads the code again so you can verify it, at any time. The agent ends a fix by reporting on each comment: fixed, not fixed, or needs clarification, with a note. The note shows under the comment, and a comment it calls fixed waits for you to verify it, since a claim is not proof. If the agent leaves its report out, Luxe asks for it once. A comment the agent still says nothing about reads `no word from the agent`, with the reason in Feedback, and stays where you left it. ## Finish `alt+enter` finishes the review: ![Finishing a review](images/review-finish.svg) | Choice | Does | | --- | --- | | **Accept** | Mark it reviewed. The agent is not told. | | **Accept and commit** | Accept, then commit each chapter as its own commit, with the chapter's explanation as the message. Never pushes. | | **Request fixes** | Send your comments for the agent to address | | **Comment only** | Send your comments without asking for edits | | **Keep reviewing** | Close this and carry on. Nothing is sent. | | **Open pull request** | Commit, push and open a pull request drafted from the review. Offered when the branch has none. See [Git and GitHub](github.md#open-a-pull-request). | | **Open as a stack** | One pull request per chapter. Offered with two chapters or more. | | **Answer on GitHub** | On your own pull request: reply to and resolve the threads you fixed | **Accept and commit** shows the commits first; `s` there makes them one. A review of a base or a commit can only be committed that way. It refuses if files are already staged or the code changed after the review was captured. ## Review a pull request `/diff pr 142` reads a pull request into the same review: a teammate's, a bot's, or one another agent opened. It needs the GitHub CLI, `gh`, signed in. ![Reviewing a bot's pull request, with its GitHub discussion](images/review-pull-request.svg) - It is pinned to the commit GitHub reported and compared from where it branched, as GitHub's **Files changed** does. Nothing in your checkout moves. - The head says who opened it and where it stands: `by dependabot (bot) · 1 failing · changes requested`. - The author's description follows the guide, under its own heading. - Review threads appear under the lines they are about. Anything without a line is under **Discussion on GitHub**. - `a` asks the agent about a line, at the pull request's commit. - `ctrl+r` reads it again after a new push. ### Post your review `alt+enter` on a pull request shows exactly what will be posted before anything is: | Key | Does | | --- | --- | | `tab` / `shift+tab` | Choose **Comment**, **Approve** or **Request changes** | | `c` | Write the summary. **Request changes** needs one. | | `d` | Have the agent draft the summary from this review | | `e` | Edit the summary | | `enter` | Post it | Line comments are listed under their files with the diff they sit on, the way GitHub shows them. Questions you asked the agent stay in Luxe and are never posted. On your own pull request, GitHub only takes a comment, so that is all Luxe offers. A drafted summary opens in the preview for you to read and edit. Anything the agent says beside it, such as a part of the change it did not read, appears under it as a note. Once the review is posted, the guide's foot says **Posted to GitHub** with the verdict, and `o` opens it. ### Fix it yourself To change someone else's pull request rather than comment on it, press `alt+u` (or `w` in the post preview). Luxe asks GitHub whether you can push to it, then checks it out in a worktree of its own and starts a session there, with your comments as the agent's first task. A plain `git push` then updates the pull request. --- # Git and GitHub > Worktrees, issues, opening and merging pull requests, review threads, and stacks. Everything that talks to GitHub goes through the GitHub CLI, `gh`, signed in with your own account (`gh auth login`). Without it, the rest of Luxe works and these features say why they cannot. ## Work in a worktree ```bash luxe --worktree # a new branch, luxe/session-… luxe --worktree fix-the-loop # a branch you name ``` The session gets its own Git worktree under `.luxe/worktrees/`, on a new branch made from `origin`'s default branch, fetched first, or from your current commit when the remote is not `origin` or `origin/HEAD` was never set. A branch you name that already exists is checked out as it is. The agent works there, so you can keep working in your own checkout and your uncommitted changes are never touched. `.luxe/` keeps its own contents out of Git, so the worktree stays out of `git status`. - `/worktree` says where the session is working. - `/worktree remove` ends the session and removes the worktree, once everything in it is committed. The branch stays. - Resuming the session goes back into its worktree, and recreates it from the branch if it was removed. - `"worktree": { "always": true }` in `config.json` gives every new session one. ## Start from an issue ```bash luxe --issue 123 ``` Luxe reads issue #123, makes a worktree on a branch named for it (`123-short-title`), and starts a session there. The issue and its latest comments go to the agent with your first message, so "fix it" is enough. They are treated as the reporter's words to weigh, not as instructions. `enter` on an issue in `/github` does the same. If one of your sessions already took the issue up, it resumes that session instead. ## Fix a pull request ```bash luxe --pr 142 # check it out in a worktree and start a session luxe --from-pr 142 # resume the session already working on it ``` A plain `git push` from the worktree updates the pull request, including one from a fork. A fork's branch that has to be renamed here, such as its `main` checked out as `mara/main`, gets a remote of its own (`mara-push-main`) for that push, so pushes from the fork's other branches are not redirected. Luxe refuses a pull request that is merged or closed, that your account cannot push to, or whose author turned off edits by maintainers. ## Open a pull request Review the work with [Guided Review](guided-review.md), finish (`alt+enter`), and choose **Open pull request**. Luxe drafts the title and description from the review, with `Closes #123` when the session started from an issue. ![The pull request, drafted from the review](images/quickstart-pull-request.svg) The heading says where it merges and what happens first, such as `commits and pushes first`. Nothing happens until you press `enter`. | Key | Does | | --- | --- | | `t` | Edit the title | | `e` | Edit the description | | `E` | Edit the description in your editor (`$LUXE_EDITOR`, `$VISUAL` or `$EDITOR`) | | `d` | Open it as a draft | | `enter` | Open it | Afterward, `o` opens it in the browser, and the session remembers it for `luxe --from-pr`. ## Browse GitHub `/github` lists this repository's pull requests and issues, with the one under the cursor shown beside the list. Each tab is a GitHub search: | Tab | Lists | | --- | --- | | **Review requested** | Open pull requests waiting on your review | | **Mine** | Your open pull requests | | **Assigned issues** | Open issues assigned to you | | **Pull requests** | Every open pull request, most recently updated first | | **Issues** | Every open issue, most recently updated first | It opens on the first tab with something in it. ![/github, with a pull request's overview](images/github-overview.svg) | Key | Does | | --- | --- | | `tab`, `shift+tab` | Next or previous tab | | `/` | Edit the tab's search | | `s` | Show open, closed, merged, or all | | `x` | Put the tab's own search back | | `→` / `←` | Into the detail and back | | `pagedown` / `pageup`, `home` / `end` | Move ten rows, or to either end | | `enter` | Review the pull request, or start a session on the issue | | `c` | [Mention](mentions.md) it in the composer | | `o` | Open it on GitHub | | `r` | Read it all again from GitHub | | `esc` | Close `/github` | ### Search The line above the rows shows the tab's search. Press `/` to edit it, then `enter` to run the tab again with your search. Use the same syntax as GitHub's search box: `is:merged`, `label:bug`, `author:mara`, `sort:created-asc`. While you type, plain words narrow the rows already loaded, so you can see a match before you search for it. ![Editing a search in /github](images/github-search.svg) A changed tab is marked with `*` and keeps your search until you close Luxe. `x` puts back the tab's own search, and so does running an empty search. The tabs always stay in this repository. A `repo:`, `org:` or `user:` you type is ignored. A tab loads 30 rows at a time and shows how many there are in all, as in `30 of 214`. Moving to the last row loads the next 30. ### Your own tabs To list other searches, replace the tabs. `{repo}` stands for this repository. A tab without it searches all of GitHub, and so does any search you type in it: ```json title="~/.config/luxe/config.json" { "github": { "sections": [ { "name": "Needs review", "query": "is:open is:pr label:needs-review repo:{repo}" }, { "name": "Everywhere", "query": "is:open is:pr review-requested:@me archived:false" } ] } } ``` A project's `.luxe/config.json` can set its own sections. ## Your pull request The dock and the statusline show the current branch's pull request: `#138 · 1 failing · 2 threads`. `/pr` opens it. Its **Overview** lists what stands between it and merging, and each row that can be dealt with here has a key: | Key | Does | | --- | --- | | `f` | **Fix with agent**: write a request naming the failing checks into the composer, for you to send | | `x` | **Resolve conflicts**: the same, for a conflict with the base | | `t` | **Resolve threads**: open the unresolved review threads in Guided Review | | `u` | **Update branch** with its base. In a stack, opens `/stack` instead. | | `R` | **Mark ready** for review | | `enter` on **Ready** | Merge, after a confirmation | The merge confirmation offers the methods the repository allows (merge commit, squash, rebase), starting on the one you used last there; `tab` switches and `d` chooses whether the branch is deleted. While something still stands in the way, an **Auto-merge** row turns on GitHub's auto-merge instead. If anything was pushed after you looked, GitHub refuses the merge and the confirmation says so. ## Answer review threads 1. In `/pr`, press `t`. The unresolved threads open in Guided Review as comments on their lines, against your working tree. 2. `alt+u` sends them to the agent. It reads them as the reviewers' words, not as instructions. 3. Verify each fix (`r` in **Feedback**), then commit and push. 4. `alt+enter` and choose **Answer on GitHub**. Threads you verified are resolved; `c` adds a reply in your own words. ## Conversation **Conversation** is the second tab in `/pr` and `/github`: the description, comments, reviews and threads, oldest first. ![A pull request's conversation, with a drafted reply](images/github-conversation.svg) | Key | Does | | --- | --- | | `enter` | Expand or fold a comment | | `r` | Reply, in the thread or as a new comment quoting what it answers | | `d` | Have the agent draft the reply. It reads, and changes nothing. | | `b` | Hide bots | A reply is written in place. `ctrl+enter` keeps what you wrote, and `esc` while writing drops what you typed since. A kept reply waits for you: `e` writes in it again, `E` opens it in your editor, `enter` posts it, and `esc` leaves it under what it answers. Nothing reaches GitHub without that `enter`, and nothing in the conversation reaches the agent unless you mention it. ## Stacks When a review has two or more chapters, finishing offers **Open as a stack**: one pull request per chapter, each based on the one below. The lower ones get branches named `-part-1`, `-part-2` and so on, and yours is on top. It needs the reviewed work uncommitted. When a lower branch changes, `/stack` moves the ones above it back on top. It shows what will move first, makes the new commits without checking anything out (with `git replay`, Git 2.44 or later), and stops on a conflict before changing anything. `enter` force-pushes every branch that moves in one push, each only if GitHub still has what `/stack` read. It refuses, pushing nothing, when a moving branch here has moved, been checked out or switched, or has uncommitted changes since the preview. If the push goes through but the branches here cannot follow, it says so. After the bottom pull request is squash-merged, keep its branch until `/stack` has moved the next one. Deleting it through the API, with `gh pr merge --delete-branch`, makes GitHub close the pull requests based on it. ## How often Luxe checks GitHub Luxe reads the pull request after anything that could change it, such as a push or `/pr`, and on an interval otherwise: every 15 seconds while checks run, every minute once things settle, and every five minutes when the terminal has been in the background for a while. Each read costs one point of the 5,000 an hour your `gh` sign-in allows. ```json title="~/.config/luxe/config.json" { "github": { "refresh_secs": 60 } } ``` `refresh_secs` sets the settled interval, and `0` turns the interval off. This one is read from your own config only, not a project's. --- # Sessions and context > Resume past sessions, see what fills the context window, and how Luxe keeps long sessions going. ## Sessions Every conversation is saved as it goes, in `.luxe/sessions/` inside the project. Quitting loses nothing. | To | In Luxe | From the command line | | --- | --- | --- | | Pick up the last session | **Resume** on the start screen | `luxe -c` | | Find an older one | Type part of its title on the start screen, or open **Sessions…** (`/sessions`) | `luxe sessions list`, then `luxe --resume ` | | Return to the session that worked on a pull request | type `#146` in **Sessions…** | `luxe --from-pr 146` | | Start fresh | `/new`, or type on the start screen and press `enter` | `luxe` | ![Sessions…, with the selected session's conversation previewed](images/sessions-picker.svg) **Sessions…** searches session titles and what was said in them, with a preview of the one under the cursor. `/sessions` opens it from the start screen or from inside a session. However you resume a session, it runs in its own worktree when it has one, and keeps what you had typed and the mode, model and thinking level you chose. `/new` keeps the session you leave, so you can come back to it. ## Cost The dock's **SESSION** section shows context used, tokens and cost as you go. `/cost` shows the session's cost with the tokens behind it, how much of the input was read from the provider's cache, and, once more than one of them has spent something, the split between `agent`, `review`, `compaction` and `cache warming`. On a ChatGPT or Copilot plan, it shows what the usage would have cost at API prices, and what is left of the plan's allowance when the provider reports it. A model that charges more for a long prompt, as models.dev lists for some Claude and Gemini models, is priced at the higher rate for each request past the threshold. A price you set in `models.json` replaces the catalog's, tiers and all. ### Keeping the cache warm Anthropic keeps a cached prompt for five minutes after it was last read. A turn that waits longer than that, on a slow command, a permission prompt or a question, loses the cache, and the next request pays to write the whole conversation again: about $2 on a 400k-token Opus session. So while a turn runs, Luxe re-sends its last request with a one-token reply just before the five minutes are up. That costs a cache read, a tenth of the write it saves. `agent.cache_warming` decides when: | Value | Keeps the cache warm | | --- | --- | | `streaming` (default) | While a turn is running | | `idle` | While a turn is running, and for up to 30 minutes after it | | `off` | Never | A refresh goes out only when it is expected to save at least $0.05, so a short conversation is never refreshed. Between turns Luxe cannot know you will reply, so `idle` refreshes only a large conversation. On real sessions it lost money overall, which is why it is not the default. Refreshes are never sent on a subscription plan, or for an Anthropic model using a fixed thinking budget, whose cache would change on replay. Their cost shows under **cache warming** in `/cost`. The other answer to a long pause is `supports_long_cache_retention`, which asks Anthropic to keep the cache for an hour; see [Custom providers](custom-providers.md#request-format). It costs more on every turn instead of on each pause. ## The context window `/context` shows what fills the context window, largest first: ```text GPT-5.6 Sol 18.5k of 1.1M · 2% used conversation 9.6k 52% `read` crat…-tui/src/statusline.rs 9.2k tool schemas 4.5k 25% instructions 2.8k 15% harness 1.5k 8% free 1.0M ``` | Row | Is | Shrink it with | | --- | --- | --- | | conversation | Messages, tool calls and results, reasoning, images | `/compact`, `/new` | | tool schemas | The tools the agent can call | `tools.disabled`; see [Tools](tools.md) | | instructions | Your `AGENTS.md` files | A shorter file; see [Project context](project-context.md) | | skills | The list of skills the agent can load | Fewer, or shorter descriptions | | harness | Luxe's own instructions | Nothing; this is fixed | | appended | One-time context added to a resumed session, such as what changed while you were away | `/new` | The largest single items are named under a row, which is usually how you find a `read` or `bash` call that should have been narrower. The total is what the provider reported for the last request. ## Compaction When a conversation fills 90% of the usable window, Luxe summarizes it and carries on, keeping the most recent part word for word. The summary records the goal, constraints, progress, decisions, next steps and the files the session touched. You see a line like `Compacted the conversation — 82k → 21k of context.` `/compact` summarizes now. Add what matters to steer the summary: `/compact the API decisions`. A conversation that already fits in what `keep_recent` keeps has nothing to summarize, and `/compact` says so. Before summarizing, from 80% full, Luxe first replaces old tool output in the request with a short placeholder. The session file keeps everything; only what is sent changes. To change when and how it happens: ```json title="~/.config/luxe/config.json" { "compaction": { "auto": true, "at": 0.9, "keep_recent": 20000, "reserve": 20000, "prune": true, "summarizer": "anthropic/claude-haiku-4-5" } } ``` | Key | Default | Does | | --- | --- | --- | | `auto` | `true` | Summarize automatically. Off leaves it to `/compact` and turns pruning off too; a request the provider refuses as too long is still summarized and sent again, once. | | `at` | `0.9` | How full the usable window gets before summarizing. Pruning starts 0.1 earlier. | | `keep_recent` | `20000` | Tokens of the newest conversation kept word for word | | `reserve` | `20000` | Most tokens held back for the reply when judging how full the window is, or the model's output limit if that is smaller | | `prune` | `true` | Replace old tool output before summarizing | | `summarizer` | the session's model | The model that writes the summary, as `provider/id`, on a provider you have set up. A smaller, cheaper model is usually enough, and `/cost` prices its spend as it does any other model's. | This works in a project's `.luxe/config.json` too, and so does `agent.cache_warming`, which a project can turn down but not up. The compaction settings in `/settings` apply the next time Luxe starts. ## Long command output Build, test and lint output is trimmed before the agent sees it. Errors, warnings, failing tests, `file:line` locations and exit codes are always kept; progress bars, color codes and repeated lines are not. When output is trimmed, the untouched text is saved under `.luxe/artifacts/` (each stream whole up to 8 MB, or its first and last 4 MB past that) and its path is included, so the agent can read it if it needs to. Set `LUXE_COMPRESS=0` to turn this off; output past the size limit is still cut and saved. Searches and listings (`grep`, `glob`, `git log`) are capped the same way, with a note saying how many more results there are. --- # Models and providers > Sign in to a provider, choose a model and thinking level, and run local models. ## Sign in Open **Configuration** (on the start screen, or `/config`) and choose how to connect. When a provider connects, the model picker opens. | Option | For | | --- | --- | | **Sign in to a subscription** | ChatGPT Plus or Pro, GitHub Copilot, or OpenRouter (which signs you in and creates a key) | | **Add an API key** | Any provider below | | **Connect a local or custom model** | LM Studio, Ollama, Azure, or any OpenAI-compatible endpoint | | **Edit configuration files** | `config.json`, `models.json`, `auth.json` and `keybindings.json`, in `$EDITOR` | From the command line: ```bash luxe auth login # choose a provider from a list luxe auth login anthropic # or name one luxe auth login openai --stdin < key.txt # read the key from a file or a pipe luxe auth list # every provider, and how each is configured luxe auth status # credential type, where it came from, when it expires luxe auth logout openai # remove a stored credential ``` In a session, `/login`, `/logout` and `/auth` do the same. Signing in checks the key with one request for the provider's model list, and gives up on an endpoint that stops answering after `LUXE_STREAM_START_TIMEOUT` seconds. Typing an endpoint that is plain `http://` and not on this machine works, with a warning that the key will cross the network unencrypted. ### Providers | Provider | ID | Environment variable | | --- | --- | --- | | OpenAI | `openai` | `OPENAI_API_KEY` | | ChatGPT Plus or Pro | `openai-codex` | subscription only | | Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | | GitHub Copilot | `github-copilot` | `COPILOT_GITHUB_TOKEN` | | OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | | Google Gemini | `google` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | | xAI | `xai` | `XAI_API_KEY` | | DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` | | Groq | `groq` | `GROQ_API_KEY` | | Cerebras | `cerebras` | `CEREBRAS_API_KEY` | | Mistral | `mistral` | `MISTRAL_API_KEY` | | Alibaba DashScope | `dashscope` | `DASHSCOPE_API_KEY` | | Z.AI | `zai` | `ZAI_API_KEY` | | Moonshot AI | `moonshotai` | `MOONSHOT_API_KEY` | | Together AI | `togetherai` | `TOGETHER_API_KEY` | | Fireworks AI | `fireworks` | `FIREWORKS_API_KEY` | | NVIDIA NIM | `nvidia` | `NVIDIA_API_KEY` | | Vercel AI Gateway | `vercel` | `AI_GATEWAY_API_KEY` | | Microsoft Foundry | `foundry-openai`, `foundry-anthropic`, `foundry-completions` | `FOUNDRY_API_KEY` | | Azure OpenAI | `azure` | `AZURE_OPENAI_API_KEY` | | LM Studio | `lmstudio` | none | | Ollama | `ollama` | none | | Any OpenAI-compatible endpoint | `custom` | | A provider's own variable works without signing in; when you have signed in too, the sign-in is used. Anthropic and Google take API keys only: their terms do not allow their subscriptions to be used from other programs. GitHub Copilot serves Claude through Anthropic's Messages API, its newer GPT, Grok and MAI models only through its Responses API, and the rest through chat completions. Copilot says which model takes which, and Luxe sends each one where it belongs. Claude goes to Messages even though chat completions would take it, because only Messages keeps its thinking, its effort level and the prompt cache for the whole conversation. The models offered are your plan's own, kept current each time the sign-in refreshes. Signing in turns on any model your plan offers that you have not been asked about yet; one you turned off stays off. ### Where credentials are kept Credentials are stored in `~/.config/luxe/auth.json` (`%APPDATA%\luxe\auth.json` on Windows), readable only by you. Subscriptions refresh on their own. A key can be the key itself, an environment variable, or a command whose output is the key, so a password manager works: ```json title="~/.config/luxe/auth.json" { "openai": { "type": "api_key", "key": "sk-..." }, "anthropic": { "type": "api_key", "key": "!op read op://vault/anthropic/credential" } } ``` A command runs each time a request needs the key. ## Choose a model `/model` opens the model picker: every model your providers can serve, configured providers first. Type to search. The session carries on with the new model, even across providers, and Luxe remembers your choice for next time. From the command line, `--model` takes a model ID, or `provider/id` to pick the provider too: ```bash luxe --model anthropic/claude-opus-5 ``` To see what Luxe knows about a model, or list what you can use: ```bash luxe models show anthropic/claude-opus-5 # context window, output limit, thinking levels, price luxe models list --provider anthropic # every model; * is active, + is configured luxe models refresh --force # update the model catalog now ``` Luxe ships with the [models.dev](https://models.dev) catalog built in and refreshes it in the background, so a model released after your copy of Luxe still has the right context window and price. ## Thinking `/thinking` sets how hard the model reasons before answering: **Off**, **Minimal**, **Low**, **Medium**, **High**, **XHigh** or **Ultra**. Levels the model does not support are grayed out. `--thinking high` sets it at launch. Luxe remembers the level per model, since `high` on a frontier model and on a small local one cost very different amounts of time. To give a model its own starting level: ```json title="~/.config/luxe/models.json" { "models": { "lmstudio/qwen3.8-27b": { "thinking": "medium" } } } ``` The picker marks that level `default`. `/thinking` can still change it during a session. ## Subscriptions Usage on a ChatGPT or Copilot subscription is counted in full but never added to what you spent. `/cost` shows what it would have cost at API prices, as `plan ≈$0.42 of usage over 12 calls, covered by your subscription`. On a ChatGPT plan, the statusline shows how much of the plan is used, such as `12% weekly`, and `/cost` shows when it resets. On a Copilot plan it shows the month's allowance the same way, such as `19% monthly`. Luxe prices Copilot usage at Copilot's per-token rates, so the figure matches what Copilot reports for each request. A Copilot plan billed by the token spends its monthly allowance at those rates. A ChatGPT subscription serves its own set of models, different from OpenAI's API, and `/model` lists exactly those. ## Local models **Connect a local or custom model** asks for the endpoint (blank takes the default) and records it in `~/.config/luxe/models.json`. No credential is needed. | Server | Default endpoint | | --- | --- | | LM Studio | `http://127.0.0.1:1234/v1` | | Ollama | `http://127.0.0.1:11434/v1` | The picker lists the models your server has loaded, asked fresh each time it opens. Embedding and reranking models are left out. For reasoning controls, chat templates and sampling settings on a local server, see [Custom providers](custom-providers.md). ## Microsoft Foundry One Foundry resource and key serve three APIs, which Luxe treats as three providers. Signing in to one configures all three: ```bash luxe auth login foundry ``` It asks for the resource name, its endpoint, or a deployment's **Target URI**, and the key. Then choose a model with the provider that serves it: | Provider | Serves | Example | | --- | --- | --- | | `foundry-openai` | OpenAI's models | `luxe --model foundry-openai/gpt-5.6-terra` | | `foundry-anthropic` | Claude | `luxe --model foundry-anthropic/claude-sonnet-5` | | `foundry-completions` | DeepSeek, Grok, Llama, Mistral, Phi and Cohere | `luxe --model foundry-completions/deepseek-v4-pro` | `/model` lists what your resource has deployed, not every model Azure offers. Luxe asks the resource for its deployments when you sign in and again each time you open `/model`, and sorts them onto the right provider by the model each one runs. `luxe models refresh` asks too. If a resource cannot list its deployments, `/model` shows Azure's catalog marked `unverified`, and you can [list them yourself](custom-providers.md#choose-which-deployments-luxe-offers). Use a deployment's name as the model ID, whatever it is called. For keyless sign-in with Microsoft Entra ID, see [Custom providers](custom-providers.md#keyless-sign-in-with-microsoft-entra-id). ## When a provider stalls Luxe waits 60 seconds for a provider to start answering, and 180 seconds between parts of a reply. If a provider is overloaded, rate limited or unreachable before it has sent anything, Luxe tries again after 2, 4 and 8 seconds, showing `retrying (2/3)` above the composer. Once a reply has started, a failure ends the turn rather than paying for the reply twice. A bad key or a refused request is not retried. | Variable | Default | Sets | | --- | --- | --- | | `LUXE_STREAM_START_TIMEOUT` | `60` | Seconds to wait for a reply to start. `0` waits forever. | | `LUXE_STREAM_IDLE_TIMEOUT` | `180` | Seconds a reply may go quiet. `0` waits forever. | | `LUXE_PROVIDER_RETRIES` | `3` | Tries after the first. `0` never retries. | --- # Tools > What the agent can use: files, the shell, tests, Git, code intelligence, a debugger and the web. ## What the agent can use | Tool | Does | | --- | --- | | `read` | Read files, several at once, with numbered lines. A long file comes back a page at a time. An image is read as a picture for models that can see. | | `write`, `edit` | Write or change a file. A file must be read before it is edited, and the card shows the diff, after [formatting](#format-on-save). | | `bash` | Run a shell command. Long output is trimmed, and the untouched output saved under `.luxe/artifacts/`: each stream whole up to 8 MB, or its first and last 4 MB past that. | | `test` | Run the project's tests and report each failure with its `file:line`, and which tests your change broke. Output is saved as `bash`'s is. | | `grep`, `glob` | Search file contents and names, with capped output | | `ast_grep` | Search and replace by code structure. Needs [ast-grep](https://ast-grep.github.io). | | `git` | Status, diffs, history, commits, branches, and GitHub pull requests, issues and CI checks through `gh` | | `todo` | The agent's checklist, shown in the dock's **PLAN** | | `question` | Ask you a question with choices, in a picker | | `skill` | Load a [skill](skills.md) | | `symbol_search`, `read_symbol`, `module_report` | Find symbols across the project and read one without the whole file | | `impact` | What else a change to a symbol or file affects: callers, dependents and covering tests | | `lsp_diagnostics`, `lsp_navigation` | Errors and warnings, definitions, references and types, from a [language server](#code-intelligence) | | `debug` | Drive a [debugger](#debugger): breakpoints, stepping, inspecting variables. Offered when a debug adapter is installed. | | `web_search`, `web_fetch` | Search the web and read pages; see [Web](#web) | | `screenshot` | Capture a web page or your own dev server, with a Chromium-based browser, or with your approval, your screen; see [Screen capture](security.md#screen-capture) | | `mcp` | Call tools on the [MCP servers](mcp.md) you configured, when there are any | Each tool has to beat the shell command it replaces. [luxe.dev/tools](https://luxe.dev/tools) shows how, measured. A call with an argument the tool does not know still runs, and its result names the argument it ignored, with the one the agent probably meant. `/tools` lists what the agent can use in the current session, and why any tool is missing. Some tools are sent with every request; the rest are listed by name and loaded when the agent first needs one, which keeps every request smaller. The `test` tool runs tests the way the project does: through its own `.venv` or the package manager its lockfile names (`uv`, `poetry`, `pnpm`, `yarn` and so on), not whatever happens to be on `PATH`. ## Turn tools off The Tools page of [`/settings`](configuration.md#settings) turns each one on or off, or set it in the file: ```json title="~/.config/luxe/config.json" { "tools": { "disabled": ["web_search", "screenshot"] } } ``` A change applies the next time Luxe starts, since the tools the agent is offered are fixed for a run. For one session, `luxe --no-tool web_search` turns a tool off and `luxe --with-tool web_search` turns one back on. `read`, `write`, `edit` and `bash` cannot be turned off. ## Format on save Each file the agent writes or edits goes through its formatter, and the result shows the file as the formatter left it. That includes a file changed by an applied rename, code action or `ast_grep` rewrite. rustfmt and gofmt always run. The others run only where the project has their config, in the file's directory or one above it: | Language | Formatter | Runs when the project has | | --- | --- | --- | | Rust | rustfmt, at the project's edition | always | | Go | gofmt | always | | Python | ruff | `ruff.toml`, `.ruff.toml`, or `[tool.ruff]` in `pyproject.toml` | | TypeScript, JavaScript | biome | `biome.json` or `biome.jsonc` | | TOML | taplo | `taplo.toml` or `.taplo.toml` | A formatter that is not installed is skipped. When Luxe pipes a file through one (taplo), an empty answer, or a file that changed again while it ran, leaves the file as it was. To turn formatting off: ```json title="~/.config/luxe/config.json" { "tools": { "format_on_save": false } } ``` ## Time limits Every tool call has a time limit, after which it is stopped: 120 seconds, or 60 for tools that wait on the network. `bash` defaults to 30 seconds and `test` to 300, and the agent can ask for longer, up to an hour. `question` waits for you as long as it takes. `LUXE_TOOL_TIMEOUT_SECS` replaces the 120- and 60-second limits; `bash` and `test` keep their own. `ctrl+esc` stops the whole batch of calls, not just the one running. A call that never started is reported as having had no effect; one stopped partway says so, since it may have done half its work. A command that starts something and leaves it running, such as a dev server, returns when the command itself ends. The server keeps running. Tools work from the directory Luxe was started in. A `cd` inside one `bash` call does not carry over to the next, so start Luxe in the project you want to work on. ## Code intelligence After every edit, Luxe asks the file's language server for errors and warnings and adds the ones the edit introduced to the result the agent sees, so it can fix what it broke before moving on. Problems that were already in the file are counted, not listed. The result also names changed code that no test covers. Set `LUXE_LSP_ON_EDIT=0` to turn both off. The agent is held to small files and functions: 500 lines and 20 lines. When an edit takes a file or a function past one of those for the first time, the result says so, so the agent can split it while the change is fresh. A file that was already long says nothing more. Functions are measured in Rust, Python, Go, TypeScript and JavaScript; files in any language. Set `LUXE_SIZE_CHECK=0` to turn this off, for a project whose own conventions differ. Luxe launches these language servers when they are installed: | Language | Server | Install | | --- | --- | --- | | Rust | rust-analyzer | `rustup component add rust-analyzer` | | TypeScript, JavaScript | typescript-language-server, `tsgo` for TypeScript 7, or `tsc --lsp` | `npm i -g typescript-language-server typescript` | | Python | pyright, or basedpyright | `npm i -g pyright` | | Go | gopls | `go install golang.org/x/tools/gopls@latest` | | C, C++ | clangd | `apt install clangd`, `brew install llvm` | | Bash | bash-language-server | `npm i -g bash-language-server` | | Ruby | ruby-lsp, or solargraph | `gem install ruby-lsp` | | Lua | lua-language-server | `mise use -g lua-language-server` | | Svelte | svelte-language-server | `npm i -g svelte-language-server` | | JSON | vscode-json-language-server | `npm i -g vscode-langservers-extracted` | | YAML | yaml-language-server | `npm i -g yaml-language-server` | A rename or code action the agent applies writes only inside the project. An edit naming a file anywhere else, or inside a [fetched repository](#github-repositories), is refused whole. Otherwise every file is written or none is, and the result says which edits didn't fit their file and which file operations, such as creating or renaming one, it left undone. `/lsp` shows which servers are running, and `luxe doctor` starts each one to check it works. Luxe never installs a server itself. When one is missing, the result tells the agent the command that installs it, which runs through `bash` under your rules like any other. ### A server the project installed Luxe prefers the copy a project installed for itself, in its `node_modules/.bin` or virtual environment, since that version matches the project. The same goes for formatters such as `ruff` and `biome`, and for MCP servers a project declares. Runs with nobody watching, such as `--headless` and the loop, use only what you installed yourself unless you pass `--trust-project`. To decide for every run, in your own config: ```json title="~/.config/luxe/config.json" { "lsp": { "project_servers": "always" } } ``` `auto` (the default) uses a project's servers when you are watching, `always` uses them every time, and `never` uses only yours. A project's own config cannot change this. A language server runs your project's build tooling. rust-analyzer runs `build.rs` and procedural macros, and a TypeScript server loads `tsconfig.json` plugins. For a repository you do not trust, use a container rather than relying on this setting. ### Add a language server ```json title="~/.config/luxe/lsp-servers.json" { "ruby": { "command": "ruby-lsp", "extensions": ["rb"], "root_markers": ["Gemfile"], "install_hint": "gem install ruby-lsp" } } ``` An entry for a language Luxe already knows changes only what it names, so `{ "rust": { "command": "rust-analyzer-nightly" } }` swaps the binary and keeps the rest. `alternatives` lists other servers to try, in order, when the first will not start. ## Debugger The `debug` tool drives a real debugger, so the agent can set breakpoints, step through code and inspect variables rather than add print statements. After three failing test runs in a row, Luxe suggests it. `LUXE_DEBUG_NUDGE=0` turns that suggestion off. | Language | Adapter | | --- | --- | | Python | debugpy (`pip install debugpy`) | | Rust, C, C++ | CodeLLDB, lldb-dap or gdb | | Go | Delve (`dlv`) | `/debug` shows which adapters are installed and any running sessions. To add one, reuse the launch style of a built-in adapter (`debugpy`, `lldb`, `gdb` or `delve`). This one debugs Zig programs with `lldb-dap`: ```json title="~/.config/luxe/debug-adapters.json" [ { "id": "zig", "command": ["lldb-dap"], "extensions": ["zig"], "dialect": "lldb" } ] ``` `transport` is `stdio` (the default) or `tcp_dial_in`, for an adapter that connects back to Luxe: put `${DAP_ADDR}` in its `command` where it takes the address, as Delve's `dlv dap --client-addr ${DAP_ADDR}` does, and Luxe replaces it with `host:port`. `addr_placeholder` names a different placeholder. A breakpoint the adapter could not set is listed under the launch, with the adapter's reason. An adapter that fails to start says why, with what it printed. ## Web `web_search` works without a key. To use a particular search provider, set its key; the first one set is used: | Key | Provider | | --- | --- | | none | Exa, through its public endpoint | | `BRAVE_API_KEY` | Brave | | `TAVILY_API_KEY` | Tavily | | `EXA_API_KEY` | Exa, on your own account | | `FIRECRAWL_API_KEY` | Firecrawl | `web_fetch` reads a page as markdown. If a page refuses, it tries again as a browser, and then, with `FIRECRAWL_API_KEY` set, in a real browser for pages built with JavaScript. `web_fetch` and `screenshot` reach the internet and your own machine (`localhost`), so your dev server works. Other machines on your network, cloud metadata addresses and `file://` are refused, including through an IPv6 address that carries one of them. A page elsewhere can't redirect a fetch to your machine, and a fetch reads at most 10 MB. `screenshot` loads only the host you asked for, so a redirect, frame or image from any other host fails to load, and it runs the browser on a fresh profile, sandboxed unless Luxe runs as root or in a container. `LUXE_WEB_ALLOW_PRIVATE=1` lifts the address checks, allows `file://` in `screenshot`, and lets it load other hosts. A fetch gives up after 30 seconds, or `LUXE_WEB_TIMEOUT_SECS`, within the call's 60-second limit. A search provider's other features, such as crawling a site, are available through its MCP server; see [MCP](mcp.md). ### GitHub repositories Given a repository address, such as `https://github.com/owner/repo`, `web_fetch` clones it instead of reading the page, so the agent can search the real files. The clone is shallow, the latest commit only, and goes into `.luxe/gh-cache/owner/repo` in your project, which git ignores. Reading it never asks permission, because it is inside the project. A repository you did not write sits inside one you did, so Luxe keeps it inert: - Symbolic links are checked out as plain files holding the link's target. A link cannot point the agent at your `.env`, or anywhere else outside the clone. - Git never prompts. A private repository needs your git credentials for github.com, for example from `gh auth setup-git`. - Git LFS files, submodules and tags are not downloaded. - Language servers and format-on-save do not start there, because rust-analyzer would run the repository's `build.rs`. - Nothing in it runs unless the agent runs it with `bash`, under your permission rules as usual. A clone over 500 MB on disk, or one that takes longer than 45 seconds, is stopped and removed. Fetching a clone more than a day old clones it again, and a clone nobody has fetched in 14 days is deleted. To clear them all, delete `.luxe/gh-cache/`. The files are still text the agent reads, and text can carry instructions, so treat a clone like any other page from the web. To read repositories as web pages instead: ```json title="~/.config/luxe/config.json" { "tools": { "clone_repositories": false } } ``` --- # How the agent works > How the agent writes to you, asks you questions, plans, and gets a second opinion. ## How it writes The agent is told to keep its replies short and direct, in every mode: - The answer first, then the reason, then any caveats - No filler, and no narrating tool calls you can already see - Plain words over jargon, casual and quick to read - One question at a time, with a recommended option - Finished work in a couple of lines: what changed, what was checked, what is left It is also told to be honest about what it knows. A guess is labeled as a guess, and "I don't know" is a complete answer. It never says something passed that it did not run, and corrects a wrong answer as soon as it notices. When it changes code, it reads what the change touches first, then reaches for the simplest thing that works: what is already in the codebase, then the standard library, then a platform feature, then a dependency the project already has. It leaves the work uncommitted unless you ask for a commit. A command that is yours to run, such as `sudo`, it gives you in a code block to run in your own terminal. None of this is a setting. To change how it talks to you, say so ("go more technical with me"), or put it in your project's [`AGENTS.md`](project-context.md) to make it stick. ## Questions When the agent needs a decision, it asks in a picker. Each option has a one-line reason, the one it recommends is marked and selected, so agreeing is one keypress. The last option is always **Type your own answer**: start typing anywhere and it's picked, so `enter` sends what you wrote. ![A question from the agent, with its recommended answer selected](images/agent-question.svg) Number keys pick an option, and in a question with one answer they send it. Your answer stays in the conversation as a card, like the one above the picker. In runs with nobody to answer, such as `--headless`, the agent takes the default it named, or else its recommended option. ## Plan, then build A session is in one of two modes, named in the composer's title and in the dock's **MODEL** section: | Mode | Does | | --- | --- | | **build** | Changes files and runs commands. The default. | | **plan** | Reads, searches and asks, and writes only `PLAN.md` | Switch with `shift+tab`, by clicking either, or with `/mode plan` and `/mode build`. `luxe --plan` starts in plan mode. In plan mode the composer, and the questions the agent asks there, take a second color. Plan mode is held in code, not asked for. An edit to any other file, a shell command or a test run is refused before it runs, and the agent is told why. Switching changes neither the system prompt nor the tools, so it does not re-read the conversation at full price. In plan mode the agent asks its open questions one at a time, then writes the plan as one `## Phase N` section per phase, numbered from 0, which is the shape [`luxe loop`](loop.md) works through. When a turn writes the plan, Luxe asks what next: - **Build it here**, with the planning conversation still in context - **Build it in a fresh session**, starting from `PLAN.md` alone - **Run it as a loop**: switch to build and say how to start [`luxe loop`](loop.md), which works phase by phase with your checks and a review at each - **Keep planning** To plan into other files, or on a stronger model: ```json title="~/.config/luxe/config.json" { "plan": { "files": ["PLAN.md", "docs/design/*.md"], "model": "anthropic/claude-opus-5", "thinking": "high" } } ``` The first plain file in `files` is where the plan goes. A model of its own costs something: each model keeps its own prompt cache, so entering plan mode on another model reads the whole conversation again at full price. `--model` or `LUXE_MODEL` wins over `plan.model`, and `--thinking` over `plan.thinking`. To start every session in plan mode: ```json title="~/.config/luxe/config.json" { "agent": { "default_mode": "plan" } } ``` ## A second opinion `/review` has a reviewer read the uncommitted changes, new files included, in a fresh context: a separate session that never sees this conversation and changes nothing. Add what to look at hardest: `/review error handling`. What it would fix goes to the agent as its next turn, and the agent may push back on a finding it thinks is wrong. A clean review is only shown. The reviewer runs on your session's model unless you name another. A fresh context is most of what makes a review useful; another vendor's model adds a second set of blind spots, for a second bill. ```json title="~/.config/luxe/config.json" { "reviewer": { "model": "openai/gpt-5.6", "thinking": "high" } } ``` The same reviewer is the second check in [`luxe loop`](loop.md#check-each-phase). ## Which model a session starts on In order, the first that is set: 1. `--model` and `--thinking` on the command line 2. `LUXE_MODEL` and `LUXE_PROVIDER` in the environment 3. The model you last chose 4. `default_model` in `models.json` Luxe never picks a model for you. With none of these set, sending a message opens the model picker, and `--headless` and the loop refuse to start. To always start from your configured default rather than your last choice: ```json title="~/.config/luxe/config.json" { "agent": { "remember_model": false } } ``` ## Turn limits A turn runs until the agent stops calling tools or you stop it with `ctrl+esc`. To cap the rounds of tool calls in one turn, where calls the model makes together count as one round: ```json title="~/.config/luxe/config.json" { "agent": { "max_tool_iterations": 200 } } ``` At the cap, the agent gets one last reply with no tools, to summarize what it did and what is left. `LUXE_MAX_TOOL_ITERATIONS` sets the cap for one run, and `0` lifts it. --- # Autonomous runs > Let the agent work through a phased plan on its own, with tests and a reviewer at each phase. `luxe loop` runs the agent on its own, phase by phase through your plan. A phase only counts as done once your tests pass and, if you set one up, a reviewer agrees. ## Write the plan Put the plan in `PLAN.md` at the project root, one heading per phase. [Plan mode](agent.md#plan-then-build) writes it in this shape for you, and when it is done, **Run it as a loop** switches the session to build and tells you to run `luxe loop start`. ![Plan mode's plan written, asking what next: build here, in a fresh session, or run it as a loop](images/loop-plan-ready.svg) ```markdown title="PLAN.md" # Word counts ## Phase 0 — Count words Read a file and print its word count. Tests for an empty file and one line. ## Phase 1 — Count by line ... ``` The agent is given one phase at a time, and the reviewer judges it against the same section. A plan without phase headings is worked on as a whole. ## Start a run ```bash luxe loop start ``` | Command | Does | | --- | --- | | `luxe loop start` | Start a run in a new session, at the phase the last one reached | | `luxe loop status` | Iteration, phase, whether it is running, and what it has cost | | `luxe loop pause`, `luxe loop resume` | Pause, and clear a pause so `start` continues | | `luxe loop stop` | Stop the run | | `luxe loop phase` | Show the phase. `--set ` changes it. | | `luxe loop fresh` | Reset the turn count, so the next `start` gets all of `--max-turns` again | | Flag | Default | Does | | --- | --- | --- | | `--max-turns` | `50` | Most turns before the run stops. The count carries over when you start again; `luxe loop fresh` resets it. | | `--compact-percent` | the `compaction.at` you set, else `75` | How full the usable context window gets before the session is summarized; see [Compaction](sessions.md#compaction) | | `--pause-percent` | `90` | How full it gets before the run stops. Keep it above `--compact-percent`, and run `luxe loop start` to go on. | | `--quiet` | | Print only the banner | A run uses your session's model, or `--model`. Only one run at a time works in a project. ## Check each phase When the agent says a phase is done, Luxe checks it before moving on: 1. **Your commands.** Each must pass. 2. **A reviewer**, if `gate.review` is on and the commands passed. It runs in a fresh session, so it judges the phase without the reasoning that produced it. It reads the phase's section of the plan, the diff and the command output, and can look around the code with read-only tools. It cannot edit. ```json title=".luxe/config.json" { "gate": { "commands": [ ["cargo", "nextest", "run", "--workspace"], ["cargo", "clippy", "--workspace", "--all-targets", "--", "-D", "warnings"] ], "timeout_secs": 600, "review": true } } ``` The reviewer runs on the run's own model unless `reviewer.model` names another, which turns `gate.review` on by itself. A local model building and a hosted one reviewing is a common pairing: the review is a few calls per phase, the building is most of them. See [A second opinion](agent.md#a-second-opinion). Each command is a program and its arguments, run without a shell. For shell features, use `["sh", "-c", "…"]`. A project's commands replace your global ones only when you start with `--trust-project`. Otherwise they're ignored with a warning, and your global commands apply. If a check fails, the findings go back to the agent as work and the phase does not advance. With neither check set up, the agent's word is taken. The same checks apply before the run may finish. A review is limited to 24 tool calls and 10 minutes. A phase only advances when the reviewer approves it. If the reviewer cannot answer, has nothing to read, or refuses the same phase three times, the loop pauses for you: fix what it needs and run `luxe loop start` again, or accept the phase with `luxe loop phase --set `. ## Watch a run A run prints one line per step, timed from its start: ```text 19s write logstats.py 49s test python3 -m unittest -q 1m09s phase 0 claimed complete — running the gate 1m31s the acceptance commands passed — asking the reviewer 2m34s phase 0 accepted — now on phase 1 ``` `luxe loop status` shows what it has cost, by model: ```text Spend this run: claude-sonnet-5: 3 calls, 26295 tokens qwen3.6-27b: 19 calls, 188660 tokens ``` The conversation is in `.luxe/sessions/`, the log in `.luxe/logs/luxe.log`, and the journal the agent keeps in `.luxe/loop/PROGRESS.md`. Past 24 KB, the journal's older `##` sections move to `PROGRESS.archive.md` beside it, and a line pointing there takes their place. The newest section stays, unless it is the only one. ## When a tool asks permission Nobody is there to answer, so a tool call that would ask you is allowed by default. To refuse those calls instead, and tell the agent to find another way: ```json title="~/.config/luxe/config.json" { "agent": { "on_ask": "deny" } } ``` `--on-ask deny` does the same for one run. A project can set `deny` but not `allow`. Calls your permission rules deny are refused either way. See [Security](security.md#when-you-step-away). --- # Configuration > Where Luxe's files live, what a project can set, and every key in config.json. Luxe works with no configuration. Everything here is optional, and most of it can be set from inside Luxe: **Configuration** (on the start screen, or `/config`) signs you in and opens these files in your editor, [`/settings`](#settings) changes the settings you use most, `/theme` sets the theme, and `/model` the model. ## Files Your own files live in `~/.config/luxe/` (`%APPDATA%\luxe` on Windows). A project can have its own in `.luxe/` at its root. | File | Holds | In a project | | --- | --- | --- | | `config.json` | Appearance, behavior, and the settings on this page | `.luxe/config.json`, over yours | | `keybindings.json` | Your keys; see [Commands and keys](commands.md#rebind-keys) | `.luxe/keybindings.json`, over yours | | `models.json` | Model and provider settings; see [Custom providers](custom-providers.md) | | | `auth.json` | Credentials; see [Models and providers](providers.md#where-credentials-are-kept) | | | `permissions.json` | What the agent may do; see [Security](security.md) | `.luxe/permissions.json`, which can only tighten yours | | `hooks.json` | Commands run at points in a turn; see [Hooks](hooks.md) | `.luxe/hooks.json`, only with `--trust-project` | | `mcp.json` | MCP servers; see [MCP](mcp.md) | `.luxe/mcp.json` or `.mcp.json` | | `lsp-servers.json` | Language servers; see [Tools](tools.md#add-a-language-server) | | | `debug-adapters.json` | Debug adapters; see [Tools](tools.md#debugger) | | | `agents.md` | Instructions for every project; see [Project context](project-context.md) | `AGENTS.md` | | `themes/` | Your own themes; see [Appearance](appearance.md#make-a-theme) | | | `skills/` | Your own skills; see [Skills](skills.md) | `.luxe/skills/` or `.agents/skills/` | `LUXE_CONFIG_DIR` moves the whole directory, and every file in it. ### What a project cannot change A project's `.luxe/config.json` overrides yours key by key, with a few exceptions. These run programs, reach the network, or decide what a repository is trusted with, so Luxe reads them from your own config only: - `launchers` and `mentions`, which run programs - `update`, and `github.refresh_secs` - `lsp.project_servers` - `agent.on_ask`, which a project can set to `deny` but not to `allow` - `agent.cache_warming`, which a project can turn down but not up, since it spends your money Project `gate.commands` also run programs. Luxe ignores them and warns unless you start with `--trust-project`. With trust, a nonempty project list replaces your global list. Otherwise, the loop uses your global commands. Move the list to your own config if you don't want to trust the project. ### Editing `luxe init` asks the first run's questions again (a theme, a provider, the tools, and the start screen) and writes the answers to `config.json`, changing only those keys. It also writes a `config.schema.json` beside it so your editor can complete and check every key. `--defaults` writes a file that chooses nothing, without asking, and so does a run with no terminal; with a `config.json` already there, either one needs `--force`, which removes the old file first. Luxe ignores a key it does not know, so an old config always loads on a newer Luxe; check the spelling if a setting seems to do nothing. `/reload` (`ctrl+shift+r`) applies changes to `config.json` and `keybindings.json` without restarting. ## Settings ![The Tools page of the settings](images/configuration-settings.svg) `/settings`, or **Settings** in **Configuration**, shows the settings you are most likely to change as controls, on five pages: Appearance, Conversation, Dock & statusline, Tools, and Agent. Each change writes one key to your `config.json` and leaves the rest of the file as it was. | To | Key | | --- | --- | | Choose a setting | `↑` / `↓` | | Turn it on or off, or step to the next choice | `space`, `enter` or `→` | | Step back a choice | `←` | | Remove it from your file, so the default applies | `r` | | Open `config.json` in your editor | `e` | | Search every page | `/` | | Change page | `tab`, or `1` to `5` | Each value says where it comes from: `default`, `global` (your `config.json`), or `project` (this project's `.luxe/config.json`, which wins over yours, so it is changed there). Most settings take effect at once; the rest say they take effect the next time Luxe starts. That includes the Tools page, because the tools the agent is offered are fixed for a run. In a window too small for the panel, it says so instead, and only `esc` works until the window fits. ## Behavior ### Notifications ```json title="~/.config/luxe/config.json" { "notifications": true, "bell": false } ``` `notifications` sends a desktop notification while the terminal is in the background, when a turn finishes or fails, a tool call waits for your approval, the agent asks you a question, or a Guided Review finishes writing its guide. To pick some of those, list them: ```json title="~/.config/luxe/config.json" { "notifications": ["permission", "question"] } ``` | Name | When | | --- | --- | | `turn` | A turn finished or failed | | `permission` | A tool call is waiting for your approval | | `question` | The agent asked you something | | `review` | A Guided Review finished writing its guide | `bell` rings the terminal bell at each of those moments. Both are off by default. Many terminals turn a bell into a request for the window manager's attention, which can pull focus from what you are doing. The notification goes through your terminal, as an escape sequence it turns into a desktop notification, so it works over ssh and needs nothing installed. Ghostty, iTerm2, kitty, WezTerm, Warp and foot show them; Luxe recognizes those and sends nothing to a terminal it does not know. If yours shows them and Luxe does not recognize it, name the sequence: `"notification_method": "osc9"` (the common one) or `"osc777"`. Inside tmux, add `set -g allow-passthrough on` to your tmux configuration. Your terminal may also have to be allowed to send notifications in your system's settings. ### herdr Inside a [herdr](https://herdr.dev) pane, Luxe tells herdr whether it is working, idle, or waiting on you, and why: `Approve bash`, or the question it asked. herdr shows that in its sidebar, raises its own notification when Luxe finishes or needs you (set in herdr's `[ui.toast]`), and reports it to `herdr agent wait`. After a herdr restart, the pane reopens the same session with `luxe --resume`. Luxe sends no escape-sequence notifications inside herdr, because herdr keeps them for itself. [Recipes](recipes.md#luxe-colors-in-herdr) has a Luxe palette for herdr's theme. This is local to your machine and on by default. Outside herdr nothing is sent. `LUXE_HERDR=0` in the environment stops the reports, and so does the config: ```json title="~/.config/luxe/config.json" { "herdr": false } ``` ### Update notice Once a day, Luxe checks whether a newer release exists and says so on the start screen, with the command to update. Luxe never updates itself. The check sends nothing about you or your machine; the comparison happens locally. ```json title="~/.config/luxe/config.json" { "update": { "check": false } } ``` `LUXE_UPDATE_CHECK=0` turns it off as well. ### Launchers A launcher gives a key to a program that takes over the terminal and gives it back, such as gh-dash or lazygit: ```json title="~/.config/luxe/config.json" { "launchers": [ { "key": "alt+g", "command": "gh-dash" }, { "key": "ctrl+alt+l", "command": "lazygit" } ] } ``` Luxe keeps running underneath and redraws when the program exits. Text the program writes to the file named by `$LUXE_HANDOFF` is put in the composer, so a gh-dash command can send `#123 look at this` to Luxe. A launcher's key wins over any other binding. ## Reference Every key in `config.json`, with its default. | Key | Default | Does | | --- | --- | --- | | `theme` | your Omarchy theme, else `luxe` | The theme; see [Appearance](appearance.md#themes) | | `colors` | | Single color overrides; see [Appearance](appearance.md#change-a-few-colors) | | `glyphs` | `auto` | Which icons to draw; see [Appearance](appearance.md#icons-and-fonts) | | `images` | `auto` | How images are drawn; see [Appearance](appearance.md#images) | | `mouse` | `true` | Capture the mouse; see [The interface](interface.md#mouse-and-clipboard) | | `dashboard` | `true` | Open on the [start screen](interface.md#the-start-screen). `false` skips it; `{ "style": "minimal" }` shows the prompt alone. | | `banner_animation` | `true` | The shine across the start screen's logotype | | `show_key_hints` | `true` | The key hint beside messages waiting for a turn | | `notifications` | `false` | Desktop notifications: `true`, or a list of moments; see [Notifications](#notifications) | | `notification_method` | `auto` | `auto`, `osc9` or `osc777` | | `bell` | `false` | Terminal bell when a turn ends or something waits on you | | `herdr` | `true` | Report the agent's state to herdr; see [herdr](#herdr) | | `statusline.visibility` | `auto` | `auto`, `shown` or `hidden`; see [Appearance](appearance.md#statusline) | | `statusline.style` | `slab` | `slab` or `flat` | | `statusline.left` | `["project", "branch", "pr", "todo", "changes"]` | Modules on the left | | `statusline.right` | `["context", "cost", "tokens", "thinking", "model"]` | Modules on the right | | `statusline.thinking_colors` | `false` | Color the thinking level | | `dock.mode` | `auto` | `auto`, `shown` or `hidden`; see [The interface](interface.md#the-dock) | | `dock.position` | `left` | `left` or `right` | | `conversation.alignment` | `full` | `full`, `left` or `center`; see [Appearance](appearance.md#the-conversation) | | `conversation.prose_max_width` | `120` | Text width under `left` or `center` | | `conversation.wide_block_max_width` | `140` | Code and table width under `left` or `center` | | `conversation.growth` | `bottom` | `bottom` or `top` | | `conversation.thinking` | `shown` | `shown`, `collapsed` or `hidden` | | `conversation.tool_runs` | `fold` | `fold` or `cards` | | `conversation.tool_details` | `errors` | `errors`, `all` or `collapsed` | | `conversation.stream_reveal` | `smooth` | `smooth` or `off` | | `conversation.activity_bar` | `bars` | `bars`, `dots` or `off` | | `conversation.scroll_lines` | `3` | Lines per wheel notch, 1 to 20 | | `conversation.image_max_rows` | `24` | Tallest image, in rows | | `diff.layout` | `auto` | `auto`, `split` or `unified`; see [Appearance](appearance.md#diffs) | | `diff.word_wrap` | `false` | Wrap long lines in Guided Review | | `review.exclude` | `[]` | Files every review leaves out; see [Guided Review](guided-review.md#what-needs-you) | | `review.context_files` | `[]` | Extra files a review reads for what the change was for, beside `PLAN.md` and `AGENTS.md` | | `tools.disabled` | `[]` | Tools to turn off; see [Tools](tools.md#turn-tools-off) | | `tools.format_on_save` | `true` | Format each file the agent changes; see [Tools](tools.md#format-on-save) | | `tools.clone_repositories` | `true` | Clone a GitHub repository `web_fetch` is given; see [Tools](tools.md#github-repositories) | | `lsp.project_servers` | `auto` | `auto`, `always` or `never`; see [Tools](tools.md#a-server-the-project-installed) | | `compaction.auto` | `true` | Summarize when the window fills; see [Sessions and context](sessions.md#compaction) | | `compaction.at` | `0.9` | How full before summarizing | | `compaction.keep_recent` | `20000` | Tokens kept word for word | | `compaction.reserve` | `20000` | Tokens held back for the reply | | `compaction.prune` | `true` | Replace old tool output first | | `compaction.summarizer` | the session's model | The model that writes the summary | | `agent.default_mode` | `build` | The mode a session starts in: `build` or `plan`; see [Plan, then build](agent.md#plan-then-build) | | `plan.files` | `["PLAN.md"]` | Files or globs plan mode may write; the first plain file gets the plan, or a file matching the first glob | | `plan.model` | the session's model | Plan mode's model, as `provider/id`; switching to it re-reads the conversation at full price | | `plan.thinking` | the session's | Plan mode's thinking level | | `reviewer.model` | the session's model | The model `/review` and the loop's gate ask; see [A second opinion](agent.md#a-second-opinion) | | `reviewer.thinking` | the session's | The reviewer's thinking level | | `reviewer.tools` | read-only tools | Tools a review may call; whatever you list, a review changes nothing | | `agent.remember_model` | `true` | Start on the model you last chose | | `agent.max_tool_iterations` | none | Most rounds of tool calls in one turn | | `agent.on_ask` | `allow` | What a permission prompt becomes with nobody to answer; see [Autonomous runs](loop.md#when-a-tool-asks-permission) | | `agent.cache_warming` | `streaming` | `off`, `streaming` or `idle`; see [Keeping the cache warm](sessions.md#keeping-the-cache-warm) | | `gate.commands` | `[]` | Commands a phase must pass; see [Autonomous runs](loop.md#check-each-phase) | | `gate.timeout_secs` | `600` | Longest one gate command may run | | `gate.review` | on when `reviewer.model` is set | Have a reviewer read each phase after the commands pass | | `worktree.always` | `false` | Give every session a worktree; see [Git and GitHub](github.md#work-in-a-worktree) | | `github.refresh_secs` | `60` | Seconds between pull request checks; see [Git and GitHub](github.md#how-often-luxe-checks-github) | | `github.sections` | | What `/github` lists | | `update.check` | `true` | Look for a newer release | | `launchers` | `[]` | Keys that open other programs; see [Launchers](#launchers) | | `mentions` | `[]` | Your own `@` sources; see [Mention sources](mention-sources.md) | --- # Appearance > Themes, the statusline, icons and fonts, the conversation's layout, diffs and images. Every setting on this page goes in `~/.config/luxe/config.json`, or a project's `.luxe/config.json`. `/reload` applies a change without restarting, except to `images`. ## Themes `/theme` previews each theme as you move through the list. `enter` keeps one and saves it to your config; `esc` puts back the theme you had. Luxe's own theme is `luxe`, a warm graphite with a brass accent. Every accent clears a 4.5:1 contrast ratio against the background, and body text 7:1. The others are dark versions of community favorites and of Omarchy's themes: | | | | --- | --- | | Luxe | `luxe` | | Editors | `jetbrains` (Darcula), `vscode` (Visual Studio 2017 Dark), `xcode` (Default Dark) | | Community | `aura`, `ayu`, `catppuccin`, `dracula`, `everforest`, `github` (Dark Dimmed), `gruvbox`, `kanagawa`, `monokai`, `noctis-bordo`, `nord`, `one-dark`, `panda`, `rose-pine`, `tokyo-night`, `vesper`, `zenburn` | | Omarchy | `ethereal`, `hackerman`, `last-horizon`, `lumon`, `matte-black`, `miasma`, `osaka-jade`, `retro-82`, `ristretto`, `solitude`, `vantablack` | ```json title="~/.config/luxe/config.json" { "theme": "nord" } ``` An unknown name falls back to `luxe`, and Luxe says so when the session opens. See them all at [luxe.dev/themes](https://luxe.dev/themes). Code is colored by the theme too: in answers, tool cards, diffs and the preview, keywords take the theme's `accentSecondary`, strings its `success`, numbers its `warning`, functions its `accent`, types its `info`, and comments and punctuation its `muted`. Markdown wears the colors it does in an answer. A theme of your own, or your Omarchy theme, colors code with no extra setup, and the website's code blocks are colored the same way. ### Change a few colors `colors` overrides single colors on top of any theme. A value is `#rrggbb`, an ANSI color number (`0` to `255`), or `""` for the terminal's own default. ```json title="~/.config/luxe/config.json" { "colors": { "accent": "#7aa2f7", "diffAddBg": "#12331f", "diffDelBg": "#3a1620" } } ``` The names are the ones a theme file uses; [`luxe.json`](https://github.com/evanwhittaker/luxe/blob/HEAD/crates/luxe-core/src/themes/luxe.json) lists every one. One more name is optional: `modePlan`, the color [plan mode](agent.md#plan-then-build) wears on the composer and the menus and questions around it. Without it, Luxe picks the first of the theme's other colors that reads as different from its accent. ### Make a theme A theme is a JSON file in `~/.config/luxe/themes/`, named for the file: `themes/harbor.json` is `"theme": "harbor"`, and it appears in `/theme` with the built-ins. Its `colors` need only what it changes; everything else comes from `luxe`. `vars` names colors for the rest of the file to use. ```json title="~/.config/luxe/themes/harbor.json" { "vars": { "harbor": "#2f7d8c" }, "colors": { "accent": "harbor", "borderAccent": "harbor" } } ``` This is also how to have a light theme. Start from a copy of [`luxe.json`](https://github.com/evanwhittaker/luxe/blob/HEAD/crates/luxe-core/src/themes/luxe.json) for a complete one. ### On Omarchy Leave `theme` unset, or set it to `omarchy`, and Luxe wears your desktop's theme and changes with it, within a couple of seconds, in a running session. It uses, in order: 1. A `luxe.json` in the current Omarchy theme. To give every theme one, write a template at `~/.config/omarchy/themed/luxe.json.tpl` and Omarchy renders it from each theme's colors. 2. Luxe's built-in theme of the same name, when both are dark. 3. A theme made on the spot from the Omarchy theme's `colors.toml`, with text lifted until it is readable. This is how a light Omarchy theme comes out. A theme you choose with `/theme` stays, whatever the desktop does, unless you choose `omarchy`. `/theme` saves to your own config, so a theme a project's `.luxe/config.json` sets still wins in that project. ## Statusline The statusline is the bar under the composer. Its modules are listed in the order they are drawn: the left half from the edge inward, the right half from the middle outward. ```json title="~/.config/luxe/config.json" { "statusline": { "visibility": "auto", "style": "slab", "left": ["project", "branch", "pr", "todo", "changes"], "right": ["context", "cost", "tokens", "thinking", "model"], "thinking_colors": false } } ``` Those are the defaults. Reorder a list to move modules, and leave one out to hide it. `project`, `branch`, `pr`, `todo`, `changes`, `cwd` and `activity` belong on the left, the rest on the right, and a module listed on the other side is not drawn. While auto-approve is on, an `auto-approve` badge sits on the right whatever the list says. | Module | Shows | | --- | --- | | `project` | The project's name | | `branch` | The Git branch, with `*` when there are uncommitted changes | | `pr` | The branch's pull request: `#138 · 1 failing · 2 threads`. Shown only when there is one. | | `todo` | The agent's checklist: `4/9 · Refine the workspace overview` | | `changes` | How many files changed, and the key for the [preview](interface.md#preview). Click it to open. | | `cwd` | The current directory, shortened | | `activity` | What the turn is doing, and for how long | | `context` | How full the context window is. Yellow past 75%, red past 90%. | | `cost` | What the session has cost, or on a subscription, what is left of the plan | | `tokens` | Tokens sent and received, and the input read from the cache | | `thinking` | The thinking level | | `model` | The model, or `no model` until you choose one | | Key | Values | | --- | --- | | `visibility` | `auto` (shown when the dock is not beside the conversation), `shown`, `hidden` | | `style` | `slab` (each module on its own block) or `flat` (plain text). The dock's headings follow it. | | `thinking_colors` | `true` colors the thinking level from muted (off) to magenta (ultra) | When the bar runs out of room, it drops modules in this order: `cwd`, `tokens`, `activity`, `cost`, `todo`, `changes`, `pr`, `branch`, `project`, then `context` and `thinking`. The model goes last. ## Icons and fonts `glyphs` sets which icons Luxe draws. Every icon has a word beside it, so nothing is lost at any setting. | `glyphs` | Draws | Needs | | --- | --- | --- | | `auto` | `nerd` when Luxe can confirm your font has every icon, otherwise `unicode` | | | `nerd` | Nerd Font icons | A Nerd Font | | `nerd_wide` | The same icons, each given two cells | A Nerd Font whose icons are drawn wide | | `unicode` | Plain shapes: `■ ► ▪ ○ ●` | UTF-8 | | `ascii` | Plain punctuation: `/ + ~ = x` | UTF-8, for the rest of the screen | ```json title="~/.config/luxe/config.json" { "glyphs": "nerd" } ``` `auto` asks fontconfig (`fc-list`) whether your fonts have every icon, and picks `nerd` in kitty, which brings its own. On macOS and Windows without fontconfig, it picks `unicode`; set `nerd` if you have a Nerd Font. If icons sit flush against the word beside them, you have the wide build of your Nerd Font: switch to its `Mono` build (see [Fonts](install.md#fonts)), or set `nerd_wide`. Fonts that draw every character Luxe uses, measured against the font files: | Font | Missing | Width | | --- | --- | --- | | JetBrains Mono | `ℹ︎ ↳︎ ↺︎ ⏸︎ ✔︎ ✖︎`, filled in by your system | 0.60 em | | IosevkaTerm | nothing | 0.50 em | | Adwaita Mono | nothing | 0.60 em | | Monaspace | `⏎︎`, used only by `luxe wire` | 0.62 em | Use IosevkaTerm's **Term** build: plain Iosevka lets some symbols overlap the next character. ## The conversation ```json title="~/.config/luxe/config.json" { "conversation": { "alignment": "full", "growth": "bottom", "thinking": "shown", "tool_runs": "fold", "stream_reveal": "smooth", "activity_bar": "bars" } } ``` | Key | Default | Values | | --- | --- | --- | | `alignment` | `full` | `full` uses the whole width. `left` or `center` cap the width at `prose_max_width` for text and `wide_block_max_width` for code and tables. | | `prose_max_width` | `120` | 72 to 120 columns, for `left` and `center` | | `wide_block_max_width` | `140` | 72 to 140 columns, for `left` and `center` | | `growth` | `bottom` | `bottom` grows up from the composer, like a chat. `top` grows down from the top. | | `thinking` | `shown` | How the model's reasoning appears: `shown`, `collapsed` to one line, or `hidden` | | `tool_runs` | `fold` | `fold` draws a run of lookups as one line. `cards` gives each call a card. | | `tool_details` | `errors` | Which tool cards start open: `errors`, `all` or `collapsed` | | `stream_reveal` | `smooth` | `smooth` paces streaming text evenly. `off` draws text as soon as it comes in. | | `activity_bar` | `bars` | The running-turn indicator: `bars`, `dots` or `off` | | `scroll_lines` | `3` | Lines per mouse wheel notch, 1 to 20 | | `image_max_rows` | `24` | The tallest an image is drawn, 3 to 200 rows | ## Diffs Tool cards, the preview and Guided Review draw diffs the same way: ```json title="~/.config/luxe/config.json" { "diff": { "layout": "auto", "word_wrap": false } } ``` | `layout` | Side by side when each column fits | | --- | --- | | `auto` | 44 columns of code | | `split` | 28 columns | | `unified` | Never | `ctrl+b` switches the preview or Guided Review for the session. A change that only adds or only removes lines is always one column. Within a changed line, the words that changed are bold. `word_wrap` wraps long lines in Guided Review instead of scrolling sideways, and `w` switches it there. Tool cards always wrap, and the preview starts wrapped; `alt+w` switches it. ## Images Screenshots and images the agent reads are drawn in the conversation where the terminal can: | `images` | Draws | Needs | | --- | --- | --- | | `auto` | `pixels` in kitty and Ghostty, `off` elsewhere and inside tmux or screen | | | `pixels` | The picture | A terminal with the kitty graphics protocol | | `blocks` | A rough preview in colored blocks | Any terminal with color | | `off` | A line naming the image, with `open ↗` to view it | | ```json title="~/.config/luxe/config.json" { "images": "auto" } ``` Double-click an image, or select its message (`alt+k`) and press `alt+i`, to view it full-window. `/images` says which setting is in effect and why. A change to `images` applies the next time Luxe starts. ## The start screen The start screen's logotype is drawn in your theme's accent, with a slow shine across it. `"banner_animation": false` holds it still. See [The start screen](interface.md#the-start-screen) for its styles, and for skipping it. --- # Project context > Give the agent your project's conventions in AGENTS.md, and how Luxe finds the project. ## AGENTS.md Put your project's conventions in an `AGENTS.md` at its root, and the agent reads it at the start of every session. In a project folder a `CLAUDE.md` works too; when both exist, `AGENTS.md` is used. The global files below are read only by those names, so `~/.claude/CLAUDE.md` is not. Luxe reads, in order: 1. `~/.agents/AGENTS.md`, the file many people keep for every agent they use 2. `~/.config/luxe/agents.md`, for every project in Luxe 3. `AGENTS.md` at the top of the repository 4. One `AGENTS.md` per folder down to where you started Luxe So a monorepo can keep shared rules at the root and package rules beside the code, and `luxe` started in `packages/app` reads both. Nothing above the repository is read. Outside a repository, only the folder you started in is, so an `AGENTS.md` in a parent folder never reaches a project it was not written for. The files are read once, when a session sends its first message, and every later request in the session repeats that same text, which is what lets the provider reuse its cache. An edit reaches the agent in the next session: `/new`, or resuming one. If you switch Git branches mid-session, the agent is told with your next message. **Project context** lists every file a turn reads, and says whose rules each one carries: **global**, **project** or **folder** context. Type `context` on the start screen and open it. `enter` opens a file in your editor, or creates one from a template. The template's guidance is in comments, which Luxe removes and reports, so replace them with your own lines. ### Duplicates A file the walk reaches twice is read once. That covers a symbolic link, such as `~/.config/luxe/agents.md` pointing at `~/.agents/AGENTS.md`, and a file that is a byte-for-byte copy of an earlier one. The context list shows the copy as *read once*. Two files that say mostly the same thing are both read, because only you can say which sentence should stay. Keep rules that apply everywhere in one global file, and the project's own rules in its `AGENTS.md`. ```markdown title="AGENTS.md" # AGENTS.md - Run `pnpm test` before calling work done; `pnpm lint --fix` formats. - Database changes go through a migration in `db/migrations/`, never by hand. - The API returns errors as `{ "error": { "code", "message" } }`. Keep it that way. - The current spec is in `docs/SPEC.md`. Read it before planning; skip it for small fixes. ``` ### Keep it short The file is part of every request, and a long one is followed less carefully, not more. For each line, ask: would a capable model get this wrong without being told, and would that cost something? Commands, pitfalls and conventions that differ from the usual pass. Directory layouts, dependency lists and architecture overviews do not; the agent reads those from the code. - A rule for one part of the tree goes in an `AGENTS.md` beside that code. - A procedure the agent needs only sometimes goes in a [skill](skills.md). - A plan, spec or design document stays a file. Name it in `AGENTS.md`, as above, and the agent reads it when the work calls for it. An `AGENTS.md` over 24,000 characters is cut off, with a note telling the agent to read the rest of the file itself. ### What Luxe removes Two kinds of text are removed from `AGENTS.md` and skill files before the agent sees them, because neither is visible to someone reviewing the file: - Comments outside code blocks: HTML comments, ``, and link definitions used as comments, such as `[//]: # (…)` - Invisible Unicode: tag characters, supplementary variation selectors, bidirectional overrides, and soft hyphens and other marks that draw nothing, written as characters or as references such as `‮` A file that repeats one link label so often that Luxe cannot check every definition is left out entirely. For an `AGENTS.md`, Luxe says what it removed or left out when it starts; a skill file is cleaned without a notice. ## Where the project begins Luxe works on one project, found by walking up from where you started it: 1. Inside a repository (`.git`, `.jj` or `.hg`), the nearest folder with a `.luxe/` in it, up to the top of the repository 2. Otherwise, the top of the repository 3. Outside any repository, the folder you are in So `luxe` started in `crates/parser/src` opens the whole repository, with its sessions, plan and reviews. The walk stops at your home folder. A Git worktree is its own project. To keep a package of a monorepo separate, give it a `.luxe/`: ```bash mkdir -p packages/app/.luxe ``` The package keeps its own sessions and still reads the `AGENTS.md` at the top of the repository. Outside a repository, a `.luxe/` claims only its own folder. Luxe writes one wherever it runs, so starting it once in `~/.config` does not make `~/.config/herdr` part of that project. --- # Extending Luxe > Which kind of extension you need, how to install one, and what stays stable across updates. Luxe extends through files and programs, never through code loaded into Luxe itself. A file carries instructions or settings; a program carries behavior, and runs with the same access you have. Installing an extension is copying a file. ## Which one you need | To | Use | Runs | | --- | --- | --- | | Change how Luxe looks, or which tools exist | [Configuration](configuration.md) and [themes](appearance.md#make-a-theme) | Nothing | | Teach the agent a procedure or house style | [Skills](skills.md) and [`AGENTS.md`](project-context.md) | Nothing | | Point the agent at your tracker or runbooks with `@` | [Mention sources](mention-sources.md) | Your program, when its tab opens | | Give the agent new tools | [An MCP server](mcp.md) | Your program, when the agent calls it | | Block, rewrite, audit or react to what the agent does | [Hooks](hooks.md) | Your program, at points in a turn | | Use an endpoint or model Luxe does not know | [Custom providers](custom-providers.md) | Nothing, or your `!command` for a key | | Drive Luxe from your own program, or build a new front end: conversation, Guided Review and GitHub | [The RPC protocol](rpc.md) | Your program, driving Luxe | | Open another terminal tool with a key | [Launchers](configuration.md#launchers) | Your program, on a key | The rows are in order of effort; the first that fits is usually right. [Recipes](recipes.md) puts several together: Asana in the `@` picker, subagents, a classifier guard, running in Herdr, and a local model in LM Studio. ## Where extensions live ```text ~/.config/luxe/ ├── config.json settings, launchers, mention sources ├── keybindings.json keys ├── models.json endpoints and models ├── permissions.json what the agent may do ├── agents.md instructions for every project ├── skills/ │ └── / a skill ├── hooks.json hooks ├── mcp.json MCP servers ├── themes/ │ └── .json a theme ├── lsp-servers.json language servers └── debug-adapters.json debug adapters / ├── .agents/skills/ skills committed with the repository ├── .mcp.json MCP servers └── .luxe/ ├── config.json settings, less what a project cannot change ├── keybindings.json ├── skills/ │ └── / ├── mcp.json ├── hooks.json only with --trust-project └── permissions.json can only tighten yours, unless --trust-project ``` What a project's `config.json` cannot change is listed in [Configuration](configuration.md#what-a-project-cannot-change). On Windows, your own folder is `%APPDATA%\luxe`, and `LUXE_CONFIG_DIR` moves it anywhere. These paths do not change between releases. `.luxe/` writes its own `.gitignore`, which keeps everything in it out of Git except `permissions.json`, `mcp.json`, `hooks.json` and `skills/`. To share more with your team, such as the project's `config.json`, write that `.gitignore` yourself: Luxe rewrites its own copy, but leaves alone one it did not write. ## Trust **A repository does not choose what runs on your machine.** Its hooks and its loop `gate.commands` run only with `--trust-project`, and its permission rules can only make yours stricter. MCP servers, language servers and formatters it brings start, by default, only in an interactive session or with `--trust-project`; `lsp.project_servers` in your own config changes that, see [Tools](tools.md#a-server-the-project-installed). This is about which programs Luxe starts. A language server itself may run project code, as rust-analyzer does with `build.rs`, so open an unfamiliar repository in Luxe the way you would build it. **A repository can still write instructions.** Its `AGENTS.md` and skills reach the model. Luxe removes text a reviewer cannot see, but plain text is still instructions, so read an unfamiliar repository's `AGENTS.md` as you would its `Makefile`. ## What stays stable across updates | | Across updates | | --- | --- | | `config.json`, `models.json`, `keybindings.json`, `lsp-servers.json`, `debug-adapters.json` | **Only added to.** An old file always loads. An unknown key is ignored, and no key changes meaning. | | Sessions in `.luxe/sessions/` | A newer Luxe reads every older session. An older Luxe skips what it does not know in a newer one. | | The RPC protocol | Adding to it changes nothing. Removing or renaming anything changes `protocol_version`. | | Hook points | Added, never renamed. An unknown point skips that hook alone, with a warning in the log. | | Tool arguments | A renamed argument still accepts its old name. | | Skills and `AGENTS.md` | Plain markdown | | The paths above | Do not move | Before 1.0, a minor release may change the RPC protocol and the hook points, and its release notes will say so. Config files and sessions keep the stronger promise regardless, since they are the two you cannot regenerate. ## What Luxe will not add - **Plugins loaded into Luxe.** A plugin would have all of Luxe's access, however it was sandboxed. - **A network listener for RPC.** It would turn a local program into a service that needs authentication. --- # Skills > Teach the agent a procedure it loads only when it needs it. A skill is a playbook in a `SKILL.md` file: how your team reviews a migration, cuts a release or writes a changelog entry. The agent sees each skill's name and description, and loads the full text only when a task calls for it, so a long skill costs nothing until it is used. ## Write a skill One folder per skill, with a `SKILL.md` in it: | Folder | For | | --- | --- | | `~/.agents/skills//` | Every project, and every agent that reads it | | `~/.config/luxe/skills//` | Every project (`%APPDATA%\luxe\skills` on Windows) | | `.agents/skills//` | This repository, committed with it | | `.luxe/skills//` | This project. Git sees it too, since `.luxe/` keeps `skills/` out of what it ignores. | `.agents/skills/` is where Codex, OpenCode, Pi, GitHub Copilot and the `npx skills` installer put skills, so one installed for any of them works here too. Luxe reads a repository's `.agents/skills/` from the top of the repository down to the project. When two skills share a name, whatever its case, the one lower in the table wins, so a project's skill replaces your own, and between nested `.agents/skills/` folders the deeper one wins. A skill reached twice through a symbolic link counts once. ```markdown title=".luxe/skills/release-notes/SKILL.md" --- name: release-notes description: > Writes the CHANGELOG entry for a user-visible change. Use when a change adds, removes or alters something a user would notice. --- 1. Read the diff and the issue it closes. 2. Add one line under `## Unreleased` in CHANGELOG.md, in the past tense, naming what changed for the user, not how. 3. Link the pull request at the end of the line. ``` Write the description for the moment the agent decides whether to load it: say when to use it, not what it contains. Without one, the agent sees only the name. `name` is optional and defaults to the folder's, and the `---` block has to start on the first line. A skill folder can also hold reference files. A loaded skill tells the agent its folder, so a skill that says *see `references/schema.md`* gets read. A skill over 40,000 characters is cut off, with a note telling the agent to read the rest of the file. ## Use a skill The agent loads a skill when its description fits the task. To ask for one yourself, mention it: `@`, `tab` to **Skills**, and `enter`, as in *split this into a stack with @[skill:gh-stack]*. **Project context** lists the skills Luxe found: type `skills` on the start screen and open it. A `SKILL.md` placed directly in `skills/`, without its own folder, is skipped, and Luxe names it at startup. The list in the agent's instructions is read with a session's first message, and again when you resume one. The `@` picker and the `skill` tool look again every time, so a skill added mid-session can be mentioned or loaded at once. ## Install a skill Skills written for other agents use the same format and work as they are. Installing one is copying its folder: ```bash git clone --depth 1 https://github.com/github/gh-stack /tmp/gh-stack mkdir -p ~/.config/luxe/skills cp -r /tmp/gh-stack/skills/gh-stack ~/.config/luxe/skills/ ``` Create `skills/` first, as above. Without it, `cp -r` copies the skill's contents to `skills/` itself, one level too high. A skill's text reaches the model as instructions, so read a skill before installing it, as you would a script. The same goes for a repository you clone: its `.agents/skills/` and `.luxe/skills/` reach the model without `--trust-project`. Luxe removes comments and invisible characters from skill files, as it does from [`AGENTS.md`](project-context.md#what-luxe-removes), but without a notice. --- # MCP servers > Give the agent tools from MCP servers: add one, sign in, and control what it may do. An [MCP](https://modelcontextprotocol.io) server gives the agent new tools: a browser, a database, your issue tracker. Luxe reaches every server through one `mcp` tool, so a server's tools cost nothing in a request until the agent looks for them: ```text mcp({ search: "screenshot" }) find tools by keyword or server mcp({ tool: "chrome/take_screenshot" }) one tool's parameters mcp({ tool: "chrome/take_screenshot", args: "{\"format\":\"png\"}" }) call it ``` ## Add a server Add servers to `~/.config/luxe/mcp.json`. The `mcpServers` format other tools write works as it is: ```json title="~/.config/luxe/mcp.json" { "mcpServers": { "chrome": { "command": "npx", "args": ["-y", "chrome-devtools-mcp@latest"] }, "linear": { "url": "https://mcp.linear.app/mcp", "auth": { "kind": "oauth" } } } } ``` A server is a local program (`command` and `args`) or a remote address (`url`). Or from the command line: ```bash luxe mcp add --id chrome --name "Chrome" --command "npx -y chrome-devtools-mcp@latest" luxe mcp list # configured servers luxe mcp tools # every server's tools luxe mcp remove --id chrome ``` `luxe mcp add` and `luxe mcp remove` change only the project's `.luxe/mcp.json`, so a server added this way is a [project's server](#servers-a-project-brings), and one in your own `mcp.json` is removed by editing that file. `luxe mcp list` starts nothing: it shows every configured server, disabled ones included, and marks a project's entries `not started (project)`, with or without `--trust-project`. In a session, `/mcp` shows each server, how it connects and its tools. Luxe also reads `~/.config/mcp/mcp.json`, and a project's `.mcp.json` and `.luxe/mcp.json`. Where two files name the same server, the project's wins. ### Server settings | Key | Does | | --- | --- | | `command`, `args`, `env` | Start a local server. An `env` value can use `${VAR}` or `$env:VAR`; an unset one is empty, and Luxe logs which. | | `url`, `headers` | Connect to a remote server. `headers` take `${VAR}` the same way. | | `auth` | Sign in: see [below](#sign-in) | | `lifecycle` | `lazy` (the default) connects on first use and disconnects when idle. `eager` connects at start; `keep_alive` stays connected. | | `timeout_secs` | How long one call may take. 60 by default. A call that runs over stops the server; the next call starts it again. | | `exclude_tools` | Tool names to hide from the agent | | `enabled` | `false` turns a server off without removing it | ## Sign in A remote server signs in with a token or with OAuth. ```json title="~/.config/luxe/mcp.json" { "mcpServers": { "internal": { "url": "https://mcp.example.com/mcp", "auth": { "kind": "bearer", "token_env": "INTERNAL_MCP_TOKEN" } }, "linear": { "url": "https://mcp.linear.app/mcp", "auth": { "kind": "oauth" } } } } ``` For OAuth, sign in once in a browser, and Luxe refreshes the token from then on: ```bash luxe mcp login --id linear ``` The agent never starts a sign-in. Tokens are kept in `~/.local/state/luxe/mcp-auth.json`, readable only by you. With `LUXE_STATE_DIR` set they are kept there instead, so a moved state directory does not share the default one's sign-ins. If that file stops being valid JSON, Luxe moves it to `mcp-auth.json.bad` (or a dated name, if one is already there) rather than write over it, and you sign in again. A token only travels over `https`. A server with `auth` set whose `url` is plain `http` is refused unless it is on this machine, and so is an OAuth endpoint, whether you wrote it or the server described it. The server's metadata must also name itself as the issuer it was asked for. Some providers, such as Asana, register each app and give you a client ID and secret. Add them, with the redirect address you registered: ```json title="~/.config/luxe/mcp.json" { "mcpServers": { "asana": { "url": "https://mcp.asana.com/v2/mcp", "auth": { "kind": "oauth", "client_id": "1209…", "client_secret": "${ASANA_MCP_CLIENT_SECRET}", "redirect_uri": "http://127.0.0.1:8765/callback" } } } } ``` The redirect must be on `127.0.0.1`, `localhost` or `[::1]`, with the port you registered. A secret or token can be written three ways: - `${VAR}`: an environment variable. Programs the agent runs can read it too. If it is unset, sign-in stops and names the variable. - `!command`: a command that prints it, such as `"!pass show asana/mcp-secret"`. It runs at sign-in and on each refresh, without a terminal, for up to 30 seconds. Only your own config can use this. - The value itself, in a file only you can read (`chmod 600`). Never put a secret in a project's `.mcp.json` or `.luxe/mcp.json`, which are shared with the repository. [Asana, end to end](recipes.md#asana) walks through the whole setup. ## Point the agent at a server In the `@` picker, the **MCP** tab lists each server with its state: `22 tools · signed in`, or `not signed in — luxe mcp login --id linear`. Mentioning one, as in *@[mcp:asana] what is due this week?*, sends the agent straight to it, with the names of its tools the first time. ## Control what a server may do A tool call is checked by your permission rules, by its `server/tool` name: ```json title="~/.config/luxe/permissions.json" { "permission": { "mcp": { "asana/get_*": "allow", "asana/create_*": "ask", "asana/delete_*": "deny" } } } ``` The most specific rule wins. `deny` holds everywhere, even with `--yolo`. `ask` holds only when someone is there to answer; in a headless or loop run it is allowed, unless `agent.on_ask` is `deny`. What a tool returns goes to your model provider, as the result of that call. Signing in and `luxe mcp tools` send nothing to a model. If a server's data should only go to a provider your organization approves, deny its tools until you are on one. A server's descriptions and results are treated as information, never as instructions. ### Servers a project brings A project's `.mcp.json` names programs that would run on your machine. Luxe starts a project's servers in an interactive session, or with `--trust-project`, but not in a headless or loop run. To decide for every run, set `lsp.project_servers` in your own config; see [Tools](tools.md#a-server-the-project-installed). `luxe mcp tools` and `luxe mcp login` do not ask whether to trust a project's servers. They skip project servers unless you pass `--trust-project` or set `lsp.project_servers` to `always` in your own config, and print the skipped server ids and how to allow them. Login refuses a skipped server before contacting its OAuth endpoints. Your global `never` setting still wins over the flag. Servers in your own config do not need project trust. --- # Mention sources > Add your own tab to the @ picker: Jira issues, Asana tasks, anything a program can list. A mention source adds a tab to the [`@` picker](mentions.md), filled by a program of yours. Pick a row, and the agent receives the text your program gave for it. ## Add a source ```json title="~/.config/luxe/config.json" { "mentions": [ { "name": "Jira", "command": "jira-mentions", "icon": "\udb80\udf03" }, { "name": "Asana", "kind": "task", "command": "asana-mentions", "args": ["--mine"] } ] } ``` | Key | Does | | --- | --- | | `name` | The tab's name | | `command`, `args` | The program to run, found on `PATH` or given as a full path, and its arguments | | `kind` | The mention's prefix, as in `@[task:1209…]`. By default the name in lowercase, with anything but letters and digits as `-`: `Jira Cloud` is `jira-cloud`. | | `icon` | One Nerd Font glyph for the tab, as the character or a JSON escape. Without one, the tab shows a tag; without a Nerd Font, a plain mark. | Sources are read from your own config only, since each one runs a program. Their tabs come after Luxe's own: Files, Skills, GitHub and MCP, whose kinds `file`, `skill`, `github` and `mcp` are taken. Two sources with the same kind get one tab, the first's. ## What the program prints Luxe runs the program, in the project, the first time its tab opens, and lists what it prints: one JSON object per line. The rows are kept until Luxe exits, so a new ticket, or a change to your script, shows up the next time you start Luxe. The program has no terminal and no stdin, so it cannot prompt for a sign-in. ```json {"id": "PROJ-12", "label": "PROJ-12 Login fails on Safari", "detail": "In progress", "text": "Jira issue PROJ-12 \"Login fails on Safari\" (In progress) https://acme.atlassian.net/browse/PROJ-12"} ``` | Field | Is | | --- | --- | | `id` | Required, a string or a number, without `]`. Goes in the mention: `@[jira:PROJ-12]`. | | `label` | Required. The row in the picker, up to 200 characters. | | `detail` | Shown beside the label, up to 200 characters | | `text` | What the agent receives, up to 600 characters. The label when missing. | A program has 15 seconds and up to 500 rows. A line that is not JSON, is over 8 KB, or lacks an `id` or `label` is skipped without a word, so a tab that stays empty usually means the output is wrong. If the program fails, the tab shows the last line it wrote to stderr, or its exit status, and it runs again the next time the tab opens. Make `text` enough for the agent to find the rest: a title and a link, or an ID it can look up with a CLI or an MCP server. A mention you type by hand, or one not picked since Luxe started, reaches the agent as `Jira PROJ-12` alone. What a tracker says is other people's words, and it reaches your model provider once you mention it. ## Example: your open Jira issues ```bash title="~/bin/jira-mentions" #!/bin/sh site=https://acme.atlassian.net curl -sSf -u "$JIRA_USER:$JIRA_TOKEN" -G "$site/rest/api/3/search/jql" \ --data-urlencode 'jql=assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC' \ --data-urlencode 'fields=summary,status' | jq -c --arg site "$site" '.issues[] | { id: .key, label: "\(.key) \(.fields.summary)", detail: .fields.status.name, text: "Jira issue \(.key) \"\(.fields.summary)\" (\(.fields.status.name)) \($site)/browse/\(.key)" }' ``` Make it executable (`chmod +x`), put it on your `PATH`, and add the Jira entry above. For Asana, see [Recipes](recipes.md#asana). Or ask Luxe to write one for the tracker you use. --- # Hooks > Run your own commands at points in a turn: block, rewrite or audit tool calls, and add context. A hook runs a command of yours at a point in a turn, such as before each tool call. It receives the event as JSON on stdin and can answer with a decision as JSON on stdout. Any executable works: a shell script, Python, a compiled program. ## Add a hook Hooks go in `~/.config/luxe/hooks.json`. This one asks before any recursive delete and refuses a wipe of `/` or your home folder: ```json title="~/.config/luxe/hooks.json" { "BeforeToolUse": [ { "matcher": { "bash": "rm *" }, "command": ["/home/you/.config/luxe/hooks/guard.sh"], "on_timeout": "deny" } ] } ``` ```bash title="~/.config/luxe/hooks/guard.sh" #!/usr/bin/env bash cmd=$(jq -r '.ToolUse.args.command // empty') case "$cmd" in *"rm -rf /"*|*"rm -rf ~"*) echo '{"decision":"deny","reason":"refusing a root or home wipe"}' ;; *) echo '{"decision":"ask","reason":"recursive delete"}' ;; esac ``` A project's `.luxe/hooks.json` runs only when you start Luxe with `--trust-project`, since opening a repository should never run its commands. When both are loaded, a project's hooks run after yours. ### Hook settings | Key | Default | Does | | --- | --- | --- | | `command` | | The program and its arguments, run without a shell. Use `["sh", "-c", "…"]` for shell features. | | `matcher` | every call | Which tool calls it runs for; see [Matchers](#matchers) | | `timeout_ms` | `5000` | One deadline for sending the event, reading output and waiting for exit. On timeout, it and everything it started are stopped. | | `on_timeout` | `allow` | What happens if the hook times out, fails to start, exits non-zero or prints something that is not a decision: `allow`, `deny` or `ask` | `on_timeout: "deny"` makes a guard fail closed. `"ask"` puts the question to you instead, at `BeforeToolUse`; at other points it acts as `allow`. A `decision` Luxe does not recognize, a typo or different casing included, gets the same treatment, with a warning in the log, and its `args` and `context` are dropped. ## Matchers A `BeforeToolUse` hook without a matcher runs on every tool call, and each run starts a process. A matcher skips the calls you do not care about, without starting anything: ```text "matcher": "bash" a tool name, or a pattern like "web_*" "matcher": { "bash": "rm *", "edit": "src/**" } a tool and what the call is about ``` The second form matches if any entry does. The patterns are the same as in [`permissions.json`](security.md): `"rm *"` finds the `rm` in `cd /tmp && rm -rf x`, and a pattern for `read` or `edit` matches the path, for `web_fetch` the address. Only `BeforeToolUse` sees a call's arguments. At `AfterToolUse` only the tool name is matched, and points without a tool call ignore the matcher. ## Hook points | Point | Runs | stdin | Can | | --- | --- | --- | --- | | `SessionStart` | A session starts or resumes, including a switch to it from another session | `{"Other": null}` | | | `SessionEnd` | A session ends, including a switch to another session; Luxe waits for the hook | `{"Other": null}` | | | `UserPromptSubmit` | You send a message, including one sent while the agent works | `{"Other": {"content": "…"}}` | block it, add context | | `BeforeAgentStart` | A turn is about to start | `{"Other": null}` | | | `AgentStart` | Luxe is about to call the model | `{"Other": null}` | | | `BeforeToolUse` | Before each tool call | `{"ToolUse": {"tool_name": "bash", "args": {…}}}` | block it, ask you, rewrite it | | `AfterToolUse` | After each tool call | `{"ToolResult": {"tool_name": "bash", "result": "…", "is_error": false}}` | add context to the result | | `FileChanged` | `write`, `edit`, an applied rename or code action, or an `ast_grep` rewrite changed a file | `{"FileChanged": {"path": "/abs/file", "operation": "edit"}}` | | | `BeforeCompact`, `AfterCompact` | Around compaction | `{"Other": {"tokens_before": 12345}}` | | | `AgentEnd` | A turn finished | `{"Other": {"effective_context_percent": 12.5}}` | | | `Error` | A turn failed | `{"Other": {"message": "…"}}` | | | `PermissionRequested`, `PermissionResolved` | An approval prompt opens or is answered | `{"Other": {"tool": "bash", "phase": "requested", …}}` | | | `ReviewGate`, `LoopControl` | `luxe loop` only | | | A hook at a point with nothing under **Can** still runs, for logging or notifications, but its answer is ignored. Point names are matched loosely (`before_tool_use` works). An unknown name skips that hook alone, with a warning naming the nearest real point. ## Answer with a decision ```json { "decision": "deny", "reason": "rm -rf is blocked by policy" } ``` | Field | Where | Does | | --- | --- | --- | | `decision` | Required in any answer | `deny` blocks the call or message. `ask` raises a permission prompt, at `BeforeToolUse`. `allow`, or no output at all, means no opinion. An answer without `decision` counts as one Luxe cannot read. | | `reason` | with `deny` or `ask` | Shown to the model when a call is blocked, to you when a message is blocked, and on the prompt | | `args` | `BeforeToolUse` | New values for the call's arguments. Keys you leave out keep their values. | | `context` | `AfterToolUse`, `UserPromptSubmit` | Text added to the tool result, or saved with your message, for the model to see | ```json { "decision": "allow", "args": { "command": "bun test" } } ``` When several hooks answer the same point, their context and rewrites are combined in order. A `deny` ends it: hooks after it do not run. ### What a hook cannot do - **Approve a call.** `allow` means no opinion; a hook can only make a call harder. To auto-approve, write a rule in [`permissions.json`](security.md). - **Rewrite its way past the rules.** A rewritten call is checked by the permission rules as rewritten, and that is what runs. If a rewritten call matches an `ask` rule, you are asked about the new command. ### When nobody is there to answer In a headless or loop run, a hook's `ask` is allowed, like every other `ask`. To change that: - `--on-ask deny`, or `"agent": { "on_ask": "deny" }` in `config.json`, refuses every `ask` in an unattended run, and tells the model why. - `{ "permission": { "hook_ask": "deny" } }` in `permissions.json` turns every hook's `ask` into a block everywhere, even when you are there. `"allow"` silences them instead. A hook's prompt offers **Allow once** and **Reject**, since a hook decides each call afresh. ## What a hook sees The event a hook receives is not redacted: it is a program you installed, with the same access you have, and the call's arguments have already gone to your model provider. What a hook returns, its `reason` and `context`, is scrubbed of anything that looks like a secret before it is shown or saved. Read a hook script before installing it, as you would anything on your `PATH`. ## Examples **Add lint output to every edit:** ```json title="~/.config/luxe/hooks.json" { "AfterToolUse": [ { "matcher": "edit", "command": ["sh", "-c", "jq -n --arg out \"$(npm run -s lint 2>&1 | tail -20)\" '{decision: \"allow\", context: $out}'"] } ] } ``` Luxe already adds language server errors after every edit; a hook like this is for a different check. **Refuse messages that contain an internal token:** ```bash title="~/.config/luxe/hooks/prompt-policy.sh" #!/usr/bin/env bash prompt=$(jq -r '.Other.content // empty') if printf '%s' "$prompt" | grep -qE 'CORP-SECRET-[0-9]+'; then echo '{"decision":"deny","reason":"the message contained an internal token"}' fi ``` **Copy the sessions before compaction:** ```json title="~/.config/luxe/hooks.json" { "BeforeCompact": [{ "command": ["sh", "-c", "cp -r .luxe/sessions /tmp/luxe-precompact-$$"] }] } ``` [Recipes](recipes.md#a-guard-that-asks-a-model) has a hook that asks a classifier model whether a command is destructive. ## Coming from Claude Code | Claude Code | Luxe | | --- | --- | | `PreToolUse`, `PostToolUse` | `BeforeToolUse`, `AfterToolUse` | | `UserPromptSubmit` with `additionalContext` | `UserPromptSubmit` with `context` | | `SessionStart`, `SessionEnd` | the same | | `PreCompact`, `PostCompact` | `BeforeCompact`, `AfterCompact` | | `Stop` | `AgentEnd`, which observes only | | `permissionDecision: "ask"` | `"decision": "ask"` | | `updatedInput` | `args`, checked again by the permission rules | | `matcher` and `if` | `matcher`, in either form | | `permissionDecision: "allow"`, which skips the prompt | No equivalent: use `permissions.json` | --- # Custom providers > Connect any endpoint, and set a model's context window, thinking levels, request format and samplers. Luxe knows how to reach every provider in [Models and providers](providers.md), and the [models.dev](https://models.dev) catalog tells it each model's context window, output limit, thinking levels and price. This page is for what neither covers: your own endpoint, a model the catalog does not know, or a local server that needs its requests shaped a particular way. Everything here goes in `~/.config/luxe/models.json`. `luxe models show /` shows what Luxe resolved for a model, and where each value came from. ## Add an endpoint **Connect a local or custom model** in **Configuration** asks for an endpoint and writes it here. By hand: ```json title="~/.config/luxe/models.json" { "providers": { "gateway": { "base_url": "https://llm.internal.example.com/v1", "api_key": "$GATEWAY_KEY", "api": "openai-completions" } }, "models": { "gateway/qwen3-coder-480b": { "context_window": 262144, "max_tokens": 65536 } } } ``` Then `luxe --model gateway/qwen3-coder-480b`, or choose it in `/model`. | Provider key | Does | | --- | --- | | `base_url` | The endpoint, including its version, such as `/v1` | | `api_key` | The key: the value, `$VAR`, `${VAR}` or `$env:VAR`, or `!command`, which runs each time a request needs the key. An unset `${VAR}` is empty, as in `mcp.json`, and Luxe logs which one. | | `api` | The API it speaks: `openai-completions` (the default), `openai-responses`, `anthropic-messages` | | `auth` | How the key is sent: `bearer`, `x-api-key`, `api-key`, or `foundry` for both of the last two | | `models_dev` | The models.dev provider to take model details from, when the names differ | | `compat` | How requests are shaped; see [Request format](#request-format) | | `sampling_params` | Samplers for every model it serves; see [Samplers](#samplers) | A provider in `models.json` replaces the same provider's block in `auth.json` whole, so keep a provider's key and endpoint in one file. A provider Luxe already knows keeps everything you do not write. A key in a provider Luxe knows is used when its own variable, such as `OPENAI_API_KEY`, is unset; for one it does not know, the key you write comes first and `OPENAI_API_KEY` is the fallback. A key you signed in with comes before either. A file Luxe cannot parse is skipped with a warning in the log, and a misspelled key is ignored, so check the result with `luxe models show /` after editing. ## Model settings Settings for one model go under `models`, by `provider/id`. Each value overrides the catalog's, and everything you leave out still comes from it. `thinking_level_map`, `modalities` and `cost` each replace the catalog's whole: a `cost` you write drops the catalog's long-prompt prices, and an `output` price you leave out is free. | Key | Does | | --- | --- | | `context_window` | The model's context window, in tokens | | `max_tokens` | The most it may write in one reply. Defaults to the model's own limit from the catalog. Luxe lowers it per request to what the window has left, so setting the real limit is safe; setting a lower one is how replies get cut off. | | `name` | The name shown in the picker and statusline | | `reasoning` | Whether it can think | | `thinking` | The level a session starts at for this model | | `thinking_level_map` | What each thinking level sends; see below | | `modalities` | What it accepts: `["text"]` or `["text", "image"]` | | `cost` | Prices per million tokens: `input`, `output`, `cache_read`, `cache_write`, and `cache_write_1h` for a write kept for an hour (twice `input` if unset) | | `compat`, `sampling_params` | Override the provider's, key by key | A model the catalog does not know gets a 128k context window and no output limit until you set them. Anthropic endpoints require a limit, so there Luxe asks for 128k. If the model allows less, Anthropic's refusal names its real limit, and Luxe retries with that and uses it for the rest of the session. ## Thinking levels `thinking_level_map` says what each of Luxe's levels sends to the model. `null` marks a level the model cannot do, removing it from `/thinking`; a level Luxe needs falls back to the nearest one that is left. `ultra` is the name for a provider's `max`. ```json title="~/.config/luxe/models.json" { "models": { "lmstudio/qwen3.8-27b": { "reasoning": true, "thinking": "medium", "thinking_level_map": { "off": "none", "minimal": null, "low": "low", "medium": "medium", "high": "high", "xhigh": "high", "ultra": null } } } } ``` A level you do not map sends its own name, and `off` sends `none`; `xhigh` and `ultra` are offered only once mapped. Setting a map also marks the model as one that reasons. An endpoint Luxe does not know sends no thinking setting at all until its `compat` says how: for most OpenAI-compatible servers, `"thinking_format": "openai"`, which sends `reasoning_effort` on every request, `off` included. See [Request format](#request-format). For Anthropic models, Luxe uses adaptive thinking with an effort level where the model supports it, and a token budget for older ones. If a model refuses how thinking was requested, Luxe tries the other way once and remembers it for the session. `compat.force_adaptive_thinking` forces adaptive thinking for an endpoint the catalog does not know. ## Request format `compat` says how a request has to be shaped for an endpoint to accept it. On a provider it applies to every model the provider serves; on a model, only the keys you write override the provider's. ```json title="~/.config/luxe/models.json" { "providers": { "lmstudio": { "base_url": "http://127.0.0.1:1234/v1", "compat": { "supports_developer_role": true, "supports_reasoning_effort": true, "thinking_format": "openai" } } }, "models": { "lmstudio/qwen3.6-27b-fable-fusion": { "compat": { "thinking_format": "chat_template", "supports_developer_role": false } } } } ``` | Flag | Does | | --- | --- | | `supports_developer_role` | Send the system prompt as `developer` rather than `system` | | `supports_reasoning_effort` | Allow `reasoning_effort` to be sent | | `thinking_format` | How thinking is requested: `openai`, `qwen`, `deepseek`, `openrouter`, `together` or `chat_template` | | `chat_template_kwargs` | What to send for `chat_template`; see [below](#chat-templates) | | `max_tokens_field` | `max_tokens` or `max_completion_tokens` | | `supports_usage_in_streaming` | Ask for token usage while streaming. On by default. | | `requires_tool_result_name` | Name the tool on each tool result. gpt-oss needs this. | | `cache_control_format` | How prompt caching is requested: `anthropic` or `openrouter` | | `force_adaptive_thinking` | Use Anthropic's adaptive thinking | | `supports_long_cache_retention` | Ask Anthropic to keep cached prompts for an hour rather than five minutes. Each cache write costs 2x input instead of 1.25x. Off by default, since not every Anthropic-compatible endpoint accepts it. | | `stream_tool_arguments` | Stream a `write` call's file from Anthropic as it is written, so its card fills in live. On by default; turn off for an endpoint that rejects a tool's `eager_input_streaming` field. | | `defer_tool_loading` | Load a tool the agent asks for with `activate_tools` where it asked, so the cached prompt stays cached. On by default. Luxe stops on its own for an endpoint that refuses it; turn it off for one that accepts it and then misbehaves. Anthropic and Responses endpoints only. | | `copilot_headers`, `openrouter_headers` | Send those services' identifying headers. With `copilot_headers`, each model also goes to the Copilot endpoint it takes. | | `use_responses_api` | Use OpenAI's Responses API. Provider only; prefer `"api": "openai-responses"`. | `anthropic-messages` endpoints read only `force_adaptive_thinking`, `cache_control_format`, `supports_long_cache_retention`, `stream_tool_arguments`, `defer_tool_loading` and `copilot_headers`. The rest are for OpenAI-style endpoints. `luxe models show` lists any flag that differs from the provider's. ## Samplers A local model's card often gives a sampling recipe with settings the OpenAI format has no field for, such as `top_k`, `min_p` and `repeat_penalty`. `sampling_params` is added to the request as written, after Luxe's own fields, so its values win: ```json title="~/.config/luxe/models.json" { "models": { "lmstudio/qwen3.8-27b": { "sampling_params": { "temperature": 1.0, "top_p": 0.95, "top_k": 20, "min_p": 0.0 } } }, "providers": { "lmstudio": { "sampling_params": { "min_p": 0.0 } } } } ``` A provider's samplers apply to every model it serves, and a model's override them one key at a time. Nothing is checked, so an unknown key goes to your server as is; `model`, `messages`, `input`, `stream` and `tools` are refused. OpenAI-style endpoints only. ## Chat templates A server that renders the model's chat template, such as `llama-server`, takes thinking settings as `chat_template_kwargs`. Each template names them differently, so you describe them: ```json title="~/.config/luxe/models.json" { "providers": { "llama-server": { "base_url": "http://127.0.0.1:8080/v1", "compat": { "thinking_format": "chat_template", "chat_template_kwargs": { "enable_thinking": { "$var": "thinking.enabled" }, "reasoning_effort": { "$var": "thinking.level", "omit_when_off": true }, "preserve_thinking": true } } } } } ``` `thinking.enabled` becomes `true` or `false`, and `thinking.level` the value `thinking_level_map` gives the current level. `omit_when_off` leaves the key out when thinking is off. Anything else is sent as written. LM Studio ignores `chat_template_kwargs`; use `"thinking_format": "openai"` there. ## Model details Luxe takes a model's details from three places, each overriding the one before, value by value: 1. The models.dev catalog built into Luxe, which works offline 2. A fresher copy, refreshed in the background into `~/.cache/luxe/models-dev.json` 3. Your `models.json` ```console $ luxe models show anthropic/claude-opus-5 anthropic/claude-opus-5 name Claude Opus 5 [models.dev] context window 1.0M [models.dev] max output 128k [models.dev] reasoning true [models.dev] thinking levels high, low, medium, ultra, xhigh [models.dev] thinking control effort (adaptive thinking), never off [models.dev] modalities text, image [models.dev] cost /Mtok $5.00/$25.00 [models.dev] ``` Each value names where it came from: `snapshot` for the catalog built into Luxe, `models.dev` for the refreshed copy, `config` for your `models.json`, and `default` where none of them said. | Variable | Does | | --- | --- | | `LUXE_MODELS_DEV_REFRESH=0` | Never refresh; the built-in catalog still answers | | `LUXE_MODELS_DEV_URL` | Refresh from a mirror | | `LUXE_MODELS_DEV_PATH` | Keep the catalog in this file instead. The refresh writes there too, so pair it with `LUXE_MODELS_DEV_REFRESH=0` to pin a file of your own. | | `LUXE_MODELS_DEV_TTL` | Seconds between refreshes. 21600 by default. | ## Microsoft Foundry ### Keyless sign-in with Microsoft Entra ID If your Foundry resource has key sign-in turned off, give Luxe a command that prints a token: ```json title="~/.config/luxe/models.json" { "providers": { "foundry-anthropic": { "auth": "bearer", "api_key": "!az account get-access-token --scope https://ai.azure.com/.default --query accessToken -o tsv" }, "foundry-openai": { "api_key": "!az account get-access-token --scope https://ai.azure.com/.default --query accessToken -o tsv" } } } ``` Run `az login` first. Only the Claude provider needs `auth`; the others already send a bearer token. The command runs for each request and nothing is kept, so a token from `az`, which lasts about an hour, is replaced as it expires and a long session carries on. ### Choose which deployments Luxe offers Luxe records what a resource has deployed in each provider's `served_models`, and the model behind any deployment named something else in `deployments`, which is where it looks up the context window and price. If your key cannot list deployments, or you want to choose the list yourself, turn discovery off and write both: ```json title="~/.config/luxe/models.json" { "providers": { "foundry-anthropic": { "discover_deployments": false, "served_models": ["sonnet-prod", "claude-opus-5"], "deployments": { "sonnet-prod": "claude-sonnet-5" } } } } ``` `discover_deployments: false` on any of the three providers stops Luxe asking the resource at all. ### Thinking on partner models `foundry-completions` sends no thinking setting by default, because its models disagree on how to take one. Turn it on per model: ```json title="~/.config/luxe/models.json" { "models": { "foundry-completions/deepseek-v4-flash": { "compat": { "supports_reasoning_effort": true }, "thinking_level_map": { "high": "high", "ultra": "max" } } } } ``` Use `reasoning_effort` only: Foundry refuses DeepSeek's own `thinking` format. Foundry does not report these models' thinking tokens, so `/cost` counts them as output. A Foundry deployment can have a different context window and price from the vendor's own API. Luxe uses Azure's figures for Foundry models. ## OpenRouter - Luxe asks OpenRouter to cache prompts for Claude models, which it otherwise does not, and which makes later turns of a session far cheaper. `"cache_control_format": null` in the provider's `compat` stops it. - Some models on OpenRouter cannot turn thinking off. For those, Luxe leaves the setting out and the model thinks at its own default. `luxe models show openrouter/` says `never off`. - Luxe identifies itself to OpenRouter with its `HTTP-Referer` and `X-Title` headers, which carry nothing about you. `"openrouter_headers": false` in `compat` drops them. --- # The RPC protocol > Drive Luxe from your own program: an editor plugin, a dashboard, or a front end of your own. `luxe --rpc` runs Luxe without its interface and speaks JSON over stdin and stdout, one object per line. Use it to build an editor plugin, a dashboard, a CI adapter, or a whole new front end, in any language. ```bash luxe --rpc # takes the usual flags: --model, --permission-mode, -c, --resume luxe --rpc --github-writes # also lets your client publish to GitHub ``` A client is trusted like the interface it replaces: it answers permission prompts, and it cannot loosen any permission rule. There is no network listener; your client is the process that started Luxe. ## A session ```text → {"kind":"ready","content":{"protocol_version":2,"luxe_version":"0.1.0","session_id":"…","cwd":"/work/app","github_writes":false}} → {"kind":"state_changed","content":{"model":{…},"streaming":false,"mode":"build","thinking":"off"}} ← {"kind":"submit_user_message","content":{"text":"what does this repo do?"}} → {"kind":"tool_started","content":{"call_id":"call_1","name":"read"}} → {"kind":"token_delta","content":{"block":0,"delta":"It is "}} → {"kind":"turn_usage","content":{"prompt_tokens":5018,"completion_tokens":26,"cost_usd":0.0258,…}} → {"kind":"turn_ended","content":{"reason":"end_turn"}} ← {"kind":"close"} ``` Every frame is `{"kind": …, "content": …}`, and a frame with no payload leaves out `content`. `ready` always comes first: check its `protocol_version` and refuse a number you do not know. ## A client ```python title="client.py" import json, subprocess luxe = subprocess.Popen(["luxe", "--rpc"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1) def send(kind, content=None): frame = {"kind": kind} | ({"content": content} if content is not None else {}) luxe.stdin.write(json.dumps(frame) + "\n") luxe.stdin.flush() for line in luxe.stdout: event = json.loads(line) kind, body = event["kind"], event.get("content") if kind == "ready": assert body["protocol_version"] == 2, "unsupported protocol" send("submit_user_message", {"text": "What does this project do?"}) elif kind == "token_delta": print(body["delta"], end="", flush=True) elif kind == "tool_started": print(f"\n[{body['name']}]") elif kind == "permission_requested": send("respond_permission", {"id": body["id"], "reply": "once"}) elif kind == "question_asked": send("answer_question", {"id": body["id"], "reply": {"answered": ["yes"]}}) elif kind == "action_rejected": print(f"\n! refused {body.get('kind')}: {body['reason']}") elif kind == "turn_ended": print() send("close") # Ignore everything else, so the client keeps working with newer versions of Luxe. ``` Three rules make a working client: read `ready` first, answer both kinds of prompt, and ignore what you do not recognize. ## Actions | `kind` | `content` | Does | | --- | --- | --- | | `submit_user_message` | `{"text": "…", "images": [{"data": "", "mime": "image/png", "name": null}]}` | Start a turn. `images` is optional. | | `steer_turn` | `{"text": "…"}` | Reach the running turn between tool calls, without ending it. With no turn running, starts one. | | `withdraw_steering` | | Drop steering messages not yet delivered | | `cancel` | | Stop the turn | | `edit_resend` | `{"text": "…"}` | Replace the last message and send it again | | `retry` | | Send the last message again | | `regenerate` | | Replace the last answer | | `respond_permission` | `{"id": "perm:…", "reply": "once"}` | Answer a permission prompt: `once`, `always`, `always_persist` or `reject` | | `answer_question` | `{"id": "ask:…", "reply": {"answered": ["postgres"]}}` | Answer a question, or `{"reply": "cancelled"}` | | `set_permission_auto_approve` | `true` | Approve every prompt for the rest of the session. Deny rules still apply. | | `select_model` | `{"provider": "anthropic", "model": "claude-sonnet-5"}` | Switch models | | `set_thinking_level` | `"high"` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` or `ultra` | | `set_mode` | `"plan"` | `build` or `plan`; see [Plan, then build](agent.md#plan-then-build). A name that is not a mode is answered by a `notice`. | | `request_review` | `{"focus": "error handling"}`, or nothing | A [second opinion](agent.md#a-second-opinion) on the uncommitted changes; answered by `review_started`, then `review_finished` | | `new_session` | | Start a new session | | `load_session` | `""` | Resume a session; answered by `session_loaded` | | `refresh_sessions` | | Answered by `sessions_refreshed` | | `refresh_workspace` | | Answered by `workspace_updated` | | `request_context_composition` | | What fills the context window; answered by `context_composition` | | `compact_now` | `{"focus": "the API decisions"}`, or nothing | Summarize now; answered by `compaction_done` | | `load_guided_review` | `{"request_id": "…", "target": {"kind": "working_tree"}}` | Capture a Guided Review; see [below](#guided-review) | | `load_file_diff` | `{"request_id": "…", "path": "src/main.rs"}` | One file's change, outside any review; answered by `file_diff_loaded` | | `list_directory` | `{"request_id": "…", "path": "src"}` | One project folder's entries, without what `.gitignore` ignores unless `"include_ignored": true`, which marks each such entry `"ignored": true`; answered by `directory_listed` | | `read_project_file` | `{"request_id": "…", "path": "src/main.rs"}` | One project file's text, up to 2 MB; answered by `project_file_read` | | `close` | | Shut down, stopping a turn in flight the way `cancel` does. The stream ends after it. | ### Guided Review A review is loaded with `load_guided_review`. Luxe answers with `guided_review_loaded`, which carries the whole review (its `id`, `revision_id`, files, hunks and chapters), and then always `review_session_restored`, which carries its comments. Everything below names the review by its `id`. An action for a review that is no longer the open one is refused. ```text ← {"kind":"load_guided_review","content":{"request_id":"r1","target":{"kind":"working_tree"}}} → {"kind":"guided_review_loaded","content":{"request_id":"r1","review":{"id":"review:…","revision_id":"…",…}}} → {"kind":"review_session_restored","content":{"session":{"id":"review:…","findings":[],…}}} ← {"kind":"edit_review","content":{"request_id":"e1","review_id":"review:…","edit":"comment","anchor":{"kind":"line","file_id":"file:…","hunk_id":"hunk:…","side":"new","line":12},"body":"Why retry twice?","severity":"question","send":true}} → {"kind":"review_session_saved","content":{"request_id":"e1","session":{…}}} ``` `edit_review` changes the comments. Every one is answered once, under its `request_id`: with `review_session_saved` and the stored comments, or `review_session_save_failed` and why. The change goes in `edit`, beside `request_id` and `review_id`: | `edit` | Fields | Does | | --- | --- | --- | | `comment` | `anchor`, `body`; optionally `severity` (`issue` or `question`), `title`, `proposed_fix`, `send` | A new comment. `anchor` is `{"kind":"line","file_id","hunk_id","side","line"}`, a `hunk`, a `chapter`, or `{"kind":"review"}`. The title defaults to the first line. | | `reply` | `finding_id`, `body`, optionally `send` | A reply on its thread. A resolved comment opens again. | | `ask_for_fix` | `finding_id`, `body`, optionally `send` | Turn an answered question into a request for the fix | | `edit_comment` | `finding_id`, `severity`, `title`, `body`, optionally `proposed_fix` | Rewrite a comment not sent yet | | `delete_comment` | `finding_id` | Delete a comment not sent yet | | `resolve`, `reopen` | `finding_id` | Mark a comment resolved, or open it again | | `reanchor` | `finding_id`, `anchor` | Move a comment whose code a refresh moved away | | `set_outcome` | `outcome`: `accepted`, `changes_requested` or `commented` | Record how the review ended. The agent is not told. | | `send` | `finding_ids`; optionally `mode` (`request_fixes`, `comment_only`, `question`), `outcome` | Send comments to the agent at the next turn. The mode defaults to what the strongest comment asks for. | | `cancel_send`, `retry_send` | `submission_id` | Withdraw a send the agent has not started, or send a failed one again | `send` on a comment, reply or fix request sends it in the same save. A sent comment's turn shows up as `review_session_saved` while it runs: the agent's answer to a question, and its report on a fix, are recorded on the comments. You can keep editing while it works. Each comment's `thread` says who said what, by `author`: `reviewer`, `agent`, or `system` for Luxe's own word. A fix turn that ends with no report on a comment leaves a `system` entry saying why, and the comment stays `open`. | `kind` | `content` | Does | | --- | --- | --- | | `edit_review` | `{"request_id", "review_id", "edit", …}` | Change the comments, as above | | `record_review_progress` | `{"review_id", "walked_chapter_ids": [...]}` | Which chapters were read. Saved when it changed, never answered. | | `record_review_seen` | `{"review_id"}` | Remember what the walked chapters covered, so the next review of the same target leads with what is new | | `refresh_guided_review` | `{"request_id", "review_id", "revision_id"}` | Read the change again, keeping the comments, if the review is still at `revision_id`. Answered like a load. | | `commit_review_chapters` | `{"request_id", "review_id", "revision_id", "chapter_ids", "squash"}` | A commit per chapter, or one with `squash`. All chapters when `chapter_ids` is left out. Never pushes. Answered by `review_committed`. | | `prepare_pull_request` | `{"request_id", "review_id"}` | Draft the pull request this branch's review would open; answered by `pull_request_prepared` | | `open_pull_request` | `{"request_id", "title", "body", "draft"}` | Open the draft `request_id` named, with your title and description. Commits the reviewed work first when the draft said so. **A GitHub write.** | | `prepare_stack` | `{"request_id", "review_id"}` | Draft a pull request per chapter; answered by `stack_prepared` | | `open_stack` | `{"request_id", "draft"}` | Open the drafted stack, bottom first. **A GitHub write.** | | `prepare_pull_request_post` | `{"request_id", "review_id"}` | Read what posting your review of a pull request needs; answered by `pull_request_post_prepared` | | `post_pull_request_review` | `{"request_id", "verdict", "session_revision"}` | Post it with `comment`, `approve` or `request_changes`. **A GitHub write.** | | `prepare_thread_answers` | `{"request_id", "review_id"}` | Whether your fixes are pushed yet, on your own pull request; answered by `thread_answers_prepared` | | `answer_threads` | `{"request_id", "review_id", "answers": [{"finding_id", "reply", "resolve"}]}` | Reply to and resolve its threads, each named by the comment it became. **A GitHub write.** | The `open_*` and `post_*` actions confirm a preview by its `request_id`. Luxe keeps what it prepared, so you send back only what a person can change: a title, a description, a verdict. `post_pull_request_review` is refused if the comments changed after the `session_revision` you drew the preview from, so what posts is what you showed. A failed open or post can be confirmed again; a stack opens once. ### GitHub These go through your own `gh`, as the interface's `/github` and `/pr` do. `repository` is `owner/name`, and you leave it out for the checkout's own repository. | `kind` | `content` | Does | | --- | --- | --- | | `list_github` | `{"sections": [{"name": "Mine", "query": "is:open is:pr author:@me repo:{repo}"}]}`, or nothing | Run `/github`'s searches. With no `sections`, runs the ones in your config. Answered by `github_listed`. | | `search_github` | `{"request": 7, "section": {…}, "after": ""}` | One search on its own, or its next page; answered by `github_searched` with your `request` | | `read_github_overview` | `{"repository": "acme/app", "number": 142, "pull_request": true}` | A pull request's or issue's status; answered by `github_overview_read` | | `read_github_conversation` | `{"number": 142, "pull_request": true}` | Everything said on it; answered by `github_conversation_read` | | `draft_github_reply` | `{"request_id": "…", "number": 142, "pull_request": true, "target": {…}, "conversation": […]}` | The agent drafts a reply in a turn that changes nothing; answered by `github_reply_drafted`. `target` and `conversation` are what `github_conversation_read` gave you. | | `check_pull_request_fix` | `{"number": 142}` | Whether it can be checked out here to fix; answered by `pull_request_fix_checked` | | `plan_restack` | `{"request_id": "restack:…"}` | What restacking this branch's stack would do; answered by `restack_planned`. Luxe keeps the plan. | | `check_worktree_removal` | | What the session's worktree has not committed; answered by `worktree_removal_checked` | | `set_focus` | `{"focused": false}` | Whether your window has focus. Luxe checks GitHub less often while it does not. | | `link_pull_request` | `{"number": 142, "url": "…"}` | Record that this session worked on that pull request | | `post_github_comment` | `{"number": 142, "thread": "PRRT_…", "body": "Fixed."}` | Post a reply in a thread, or a comment without `thread`; answered by `github_comment_posted`. **A GitHub write.** | | `change_pull_request` | `{"number": 142, "write": {"change": "mark_ready"}}` | `update_branch`, `mark_ready`, `merge`, `enable_auto_merge` or `disable_auto_merge`; answered by `pull_request_changed`. **A GitHub write.** | | `restack` | `{"request_id": "restack:…"}` | Push the plan `plan_restack` made under that id, exactly as planned; answered by `restacked` or `restack_failed`. **A GitHub write.** | You confirm a restack by its id, not by sending the plan back: Luxe pushes the plan it showed you. A newer `plan_restack` replaces the older plan, and a plan is pushed once. Before pushing, Luxe checks that every moving branch here is where the plan saw it, checked out where it was, and clean, and refuses with `restack_failed` otherwise. A push that did not finish is also `restack_failed`, and its message says to check the branches on GitHub, since some may have moved. If the push succeeds but the branches here, or a pull request's base on GitHub, cannot follow, the answer is `restacked`, with what went wrong in `retarget_failed`. A `restack_planned` with nothing to restack, such as no open pull request or no `gh`, carries a `refusal` and no `plan`. `--issue`, `--from-pr` and `--worktree` work with `--rpc` as they do anywhere, since they decide the session before the first frame. See [Git and GitHub](github.md). An unknown `kind` is answered with `action_rejected`, which is also how to find out what a build supports. So is a malformed line, and nothing else happens: one bad line never ends the session. ```text ← {"kind":"steer_turn","content":{}} → {"kind":"action_rejected","content":{"kind":"steer_turn","reason":"steer_turn: missing field `text`"}} ``` ## Events There are around sixty. These are the ones a front end is built on: | `kind` | Is | | --- | --- | | `ready` | The handshake: protocol version, Luxe version, session ID, directory | | `token_delta`, `thinking_delta` | The reply and reasoning as they stream. `block` orders them. | | `tool_call_composing` | The model has started writing a tool call. Optional; useful for showing a `write` filling in. | | `tool_started`, `tool_progress`, `tool_completed`, `tool_ended` | A tool call's progress, by `call_id`. `tool_completed` carries its output. | | `tool_activity_updated` | The card the interface draws for a call: title, body, state and timing | | `message_committed` | A complete message was added to the transcript | | `permission_requested` | **Answer it** with `respond_permission`, or the turn waits. If `always_patterns` is empty, offer only `once` and `reject`. | | `question_asked` | **Answer it** with `answer_question` | | `turn_ended` | The turn finished; `reason` says why | | `provider_retrying` | The provider failed before answering, and Luxe is trying again | | `state_changed` | Model, thinking level, mode, and whether a turn is running | | `plan_written` | Plan mode wrote `path`. The interface offers the plan's next step when the turn ends. | | `review_started`, `review_finished` | A requested review began on `model`, and came back with a `verdict` (`continue`, `fix` or `unavailable`) and `findings`. On `fix` the findings also become the agent's next turn. | | `turn_usage`, `session_usage`, `plan_usage` | Cost per call, for the session, and what a subscription has left | | `context_usage`, `context_composition` | How full the context window is, and with what | | `workspace_updated` | The project, Git changes, todos and tools: what the dock shows | | `session_loaded`, `sessions_refreshed` | A transcript was restored; the session list changed | | `notice`, `error` | Something to show. `error` ends the turn. | | `action_rejected` | Your frame was refused and nothing happened | Guided Review, as the interface sees it: | `kind` | Is | | --- | --- | | `guided_review_loading`, `guided_review_loaded`, `guided_review_failed` | A review you asked for, by `request_id`. `loaded` carries the whole review: files, hunks and chapters. | | `guided_review_guide_updated` | Better chapters and a summary replaced the first ones, for the same pinned revision | | `review_session_restored` | The comments and progress that go with the review just loaded. It always follows `guided_review_loaded`, after a restart too. | | `review_session_saved`, `review_session_save_failed` | A change to them was stored, or refused. Luxe reports its own changes this way too: a turn starting, the agent's report, a review posted. | | `review_feedback_turn_dispatched`, `review_feedback_turn_completed`, `review_feedback_turn_failed` | Feedback you sent reaching the agent, and how its turn went | | `review_questions_answered`, `remediation_reported` | The agent's answer to a question, and its report on a fix | | `review_committed`, `review_commit_failed` | Committing a review's chapters | | `last_turn_available` | The agent's last turn can be reviewed on its own | | `file_diff_loaded`, `file_diff_failed` | One file's change, for `load_file_diff` | | `directory_listed`, `directory_list_failed` | One folder's entries, for `list_directory` | | `project_file_read`, `project_file_read_failed` | One file's text, for `read_project_file` | GitHub: | `kind` | Is | | --- | --- | | `github_listed`, `github_searched` | Search results, with each section's rows or why it has none | | `github_overview_read`, `github_conversation_read` | One pull request or issue: its status, or everything said on it | | `github_reply_drafted`, `github_comment_posted` | A draft to edit and send, and where a sent one was posted | | `pull_request_changed`, `pull_request_fix_checked` | What a change did, and whether a fix can be checked out here | | `restack_planned`, `restacked`, `restack_failed` | A restack's plan, and what pushing it did | | `pull_request_prepared`, `pull_request_opened`, `pull_request_open_failed` | Opening a pull request from a review | | `stack_prepared`, `stack_opened` | Opening a review as a stack | | `pull_request_post_prepared`, `pull_request_review_posted`, `pull_request_post_failed` | Posting your review of a pull request | | `thread_answers_prepared`, `threads_answered`, `thread_answers_failed` | Replying to and resolving review threads on your own pull request | | `worktree_removal_checked` | What removing the session's worktree would lose | The branch's pull request, its checks and its review status come in `workspace_updated`, as they do for the interface's dock. Treat an event you do not recognize as nothing. ## Versioning `protocol_version` is `2`. New events, actions and fields do not change it, since a client that ignores what it does not know already handles them. It changes only when something a client could have relied on is removed or renamed. So dispatch on `kind`, ignore the rest, and do not assume the list is closed. ## Security - Luxe listens on nothing; your client is the process that started it. - Permission rules are enforced inside Luxe. A client cannot weaken a `deny`: reading `.env` is refused here as everywhere. - Prompts go to your client to answer. To approve everything, send `set_permission_auto_approve` or start with `--auto-approve`. `--on-ask` is refused, since your client can answer. - `--permission-mode`, `--read-only`, `--yolo` and `--trust-project` apply as usual. ### GitHub writes Your client can read GitHub without asking. To let it publish there (post a comment or a review, answer threads, open, change, merge or restack a pull request), start Luxe with `--github-writes`. Without it, those actions are refused with `action_rejected`, and `ready` says `"github_writes": false` so you can hide them. - **Read-only wins.** With `--read-only`, or a read-only mode in your permissions file, the writes are refused even with the flag, and so is `commit_review_chapters`. - **It is a flag, not a setting.** Whoever starts Luxe decides, for that session. No config file turns it on, and a project cannot. - **It covers your client, not the agent.** The agent's own GitHub writes are permission rules: they ask first, and the prompt comes to your client. See [Security](security.md#when-you-step-away). Luxe is not a sandbox. To isolate it, run it in a [container](security.md#isolation). --- # Recipes > Extensions put together: Asana, subagents, a classifier guard, Herdr, and a local model. ## Asana Two ways to bring Asana in, and they work together: its MCP server, so the agent can read and update tasks, and a mention source, so you can point at one task with `@`. ### The MCP server Asana registers each app, so each person makes one of their own. 1. **Register an MCP app** in Asana's developer console (**My apps**). Set its redirect URL to `http://127.0.0.1:8765/callback`, and leave **This is a native or command-line app** unchecked. Share it with your workspace. Your organization may need an admin to approve it. 2. **Keep the secret in your environment.** In `~/.zshrc` or `~/.bashrc`, then open a new terminal: ```bash export ASANA_MCP_CLIENT_SECRET='…' ``` 3. **Add the server**, with your app's client ID, and hide the tools Asana marks as internal: ```json title="~/.config/luxe/mcp.json" { "mcpServers": { "asana": { "url": "https://mcp.asana.com/v2/mcp", "auth": { "kind": "oauth", "client_id": "1209…", "client_secret": "${ASANA_MCP_CLIENT_SECRET}", "redirect_uri": "http://127.0.0.1:8765/callback" }, "exclude_tools": [ "get_project_internal", "search_objects_internal", "log_widget_event", "create_task_confirm", "save_task_changes_confirm", "create_project_confirm", "create_project_confirm_populate", "save_project_changes_confirm" ] } } } ``` 4. **Sign in** and check what you can use: ```bash luxe mcp login --id asana luxe mcp tools ``` 5. **Decide what the agent may do.** Task names and comments go to your model provider when a tool returns them. To let reads through and ask before anything changes: ```json title="~/.config/luxe/permissions.json" { "permission": { "mcp": { "asana/create_*": "ask", "asana/update_*": "ask", "asana/delete_*": "ask", "asana/add_*": "ask" } } } ``` If Asana's data may only go to a provider your organization approves, use `"asana/*": "deny"` until you are on one. In a headless or loop run, make the writes `deny`: nobody is there to answer an `ask`. 6. **Use it:** *@[mcp:asana] what is due this week?* ### Your tasks in the `@` picker This needs a personal access token (**My apps → Personal access tokens**), since the MCP sign-in's token works only for the MCP server. Your workspace's ID is in `https://app.asana.com/api/1.0/workspaces`, read with the same token. ```bash title="~/bin/asana-mentions" #!/bin/sh curl -sSf -H "Authorization: Bearer $ASANA_PAT" -G https://app.asana.com/api/1.0/tasks \ --data-urlencode assignee=me --data-urlencode "workspace=$ASANA_WORKSPACE" \ --data-urlencode completed_since=now --data-urlencode limit=100 \ --data-urlencode opt_fields=name,due_on,permalink_url | jq -c '.data[] | (if .due_on then "due \(.due_on)" else "no due date" end) as $due | { id: .gid, label: .name, detail: $due, text: "Asana task \"\(.name)\" (\($due)) \(.permalink_url)" }' ``` ```json title="~/.config/luxe/config.json" { "mentions": [{ "name": "Asana", "command": "asana-mentions" }] } ``` A task you pick goes in as `@[asana:1209…]` and reaches the agent as its name, due date and link, and the agent can look it up through the MCP server. See [Mention sources](mention-sources.md). ## Subagents A subagent is a separate context that does one job and returns a summary, which is what a headless Luxe run is: ```bash luxe --headless --read-only --model openai/gpt-5.6 --format json \ -p "review src/parser.rs for edge cases" | jq -rs 'map(select(.type == "message" and .text != "")) | last.text' ``` `--read-only` keeps the subagent from changing anything, and `--model` lets it be a different model from the one doing the work. The `jq` filter keeps the last message with text in it, which is the summary. For a review of your own uncommitted work, [`/review`](agent.md#a-second-opinion) does this without the wrapper. To let the agent start one itself, wrap that command in an MCP server with a `delegate(task, model)` tool. Then: - Rule it like any tool: `"mcp": { "delegate/*": "ask" }` in `permissions.json` puts each one in front of you. - Give the server a `timeout_secs` longer than a review takes. A call gets 60 seconds by default, and one that runs out stops the subagent. - Return the subagent's stderr when it fails, so the agent sees why. - Give a subagent that writes its own worktree, with `git worktree add`, so it stays out of yours. Luxe refuses to nest more than two levels deep, since an agent that starts two subagents per turn would multiply fast. `LUXE_MAX_DEPTH` raises the limit. ## A guard that asks a model A `BeforeToolUse` [hook](hooks.md) can ask a small classifier model whether a command is destructive, and ask you when it might be. This one calls a typed decision model running on your own machine, and the request follows that model's API; any model that returns a probability works. ```bash title="~/.config/luxe/hooks/risk-gate.sh" #!/usr/bin/env bash set -euo pipefail command=$(jq -r '.ToolUse.args.command') risk=$(jq -n --arg c "$command" '{ state: { command: $c }, questions: { destructive: { type: "noul", instructions: "Would running this command cause irreversible data loss?" } } }' | curl -sS --max-time 2 "${DECISION_URL:-http://localhost:8000}/v1/systemone" \ -H 'Content-Type: application/json' --data @- | jq -r '.answers.destructive.noul') if awk "BEGIN{exit !($risk > 0.8)}"; then jq -n --arg r "scored $risk for irreversible data loss" '{decision:"ask", reason:$r}' else echo '{"decision":"allow"}' fi ``` ```json title="~/.config/luxe/hooks.json" { "BeforeToolUse": [ { "matcher": "bash", "command": ["/home/you/.config/luxe/hooks/risk-gate.sh"], "timeout_ms": 2500, "on_timeout": "ask" } ] } ``` - **It asks rather than blocks.** A hook can only make a call harder, so the worst a wrong answer costs is a prompt. - **It adds to your rules, not replaces them.** Keep what must always hold in [`permissions.json`](security.md). - **It runs before every matching call.** The `matcher` keeps it off `read` and `grep`; narrow it further, such as `{ "bash": "rm *" }`, to cost less. - **`on_timeout: "ask"`** puts the question to you if the classifier is down. - **Nobody answers in a headless run**, so its `ask` is allowed there. Use `--on-ask deny`, or `"hook_ask": "deny"` in `permissions.json`, to make it hold. ## Run Luxe in Herdr [Herdr](https://herdr.dev) keeps coding agents in panes and shows which ones are working, waiting on you, or done. Luxe reports its state to Herdr on its own when it runs in a Herdr pane: **working** during a turn, **blocked** when it needs you, and **done** when the turn ends. ```bash curl -fsSL https://herdr.dev/install.sh | sh herdr luxe # in a pane ``` To let the agent drive Herdr, such as splitting panes or starting other agents, add Herdr's skill: ```bash mkdir -p ~/.config/luxe/skills/herdr curl -fsSL https://raw.githubusercontent.com/ogulcancelik/herdr/master/SKILL.md \ -o ~/.config/luxe/skills/herdr/SKILL.md ``` `LUXE_HERDR=0` or `{ "herdr": false }` in Luxe's config turns the reporting off. ### Luxe colors in Herdr Herdr has no theme files — it takes a built-in theme plus per-token overrides. This is Luxe's own palette in those tokens, so the pane and the multiplexer around it read as one program: ```toml title="~/.config/herdr/config.toml" [theme] name = "vesper" # the closest built-in; every token below is overridden anyway [theme.custom] accent = "#ddc084" # champagne, the one color Luxe leads with text = "#e4dfd6" subtext0 = "#97938c" overlay0 = "#4d4a45" overlay1 = "#97938c" sidebar_bg = "#12100e" # the base surface, same as Luxe's background panel_bg = "#12100e" # the tab bar sits on it too, so the chrome is one field surface0 = "#201e1a" surface1 = "#2e2b27" surface_dim = "#201e1a" active_row_bg = "#201e1a" # the current space, at rest selection_bg = "#2e2b27" # the Navigate cursor, which should be the brighter of the two mauve = "#d2a5cd" # branch names yellow = "#ddc084" # working — an agent at work glows in the accent green = "#81caa0" # idle / done red = "#e68485" # blocked, needs you peach = "#faaa51" # interrupted blue = "#85c7d7" # Luxe has one cool hue, so blue and teal share it teal = "#85c7d7" [ui] accent = "#ddc084" # borders and navigation chrome, which read off their own key ``` Reload it in place with `prefix+shift+r`, or `herdr server reload-config`. For any of the other [themes](appearance.md) Luxe ships, the hexes are in its theme file and the token names above do not change. The one token worth a second thought is `panel_bg`: Herdr draws the active tab as `panel_bg` text on an `accent` fill, so it has to stay dark enough to read there. Lifting it to `#201e1a` gives the tab bar a surface of its own if you prefer the chrome delineated. ## A local model in LM Studio 1. Load a model in LM Studio and start its server. 2. In Luxe, open **Configuration** and choose **Connect a local or custom model**, then **LM Studio**. Leave the endpoint blank for the default. 3. Choose the model in the picker that opens. LM Studio ignores the chat-template settings Luxe sends a local server by default, so for thinking levels to reach the model, send them as `reasoning_effort`, and start each session at a sensible level. Connecting already wrote an `lmstudio` provider to `models.json`, so add these keys to it rather than replacing the file: ```json title="~/.config/luxe/models.json" { "providers": { "lmstudio": { "compat": { "thinking_format": "openai" } } }, "models": { "lmstudio/qwen3.8-27b": { "reasoning": true, "thinking": "medium" } } } ``` [Custom providers](custom-providers.md) covers samplers and request formats. --- # Security and permissions > What the agent may do, the rules and prompts that decide it, secrets, and what Luxe sends over the network. The agent reads and writes files and runs commands on your machine. Luxe lets ordinary work through without asking, keeps a few guardrails on at all times, and gives you rules to tighten the rest. For real isolation, run it in a [container](#isolation). ## What is allowed by default Every tool call is **allowed**, **asked** about, or **denied**. With no configuration: | | | | --- | --- | | **Denied** | Reading `.env` and `.env.*` files (`.env.example` is fine). Catastrophic commands: wiping `/`, your home folder or a system folder, `mkfs`, `dd` to a device, fork bombs. Running a command as root; see [below](#commands-that-need-root). The same call with the same arguments a third time in a row: the agent is told it already has the result. | | **Asked** | Anything outside the project folder. `git push --force`. Committing. Writes to GitHub: opening, merging or commenting on pull requests and issues, and re-running CI. Capturing your screen. | | **Allowed** | Everything else | Commands are matched piece by piece, so `cd /tmp && rm -rf /` is caught. These are guardrails, not a security boundary: a model set on getting around a pattern can. Committing asks because a commit moves what Guided Review compares against; answer **Allow always** once and it stops asking. Recursive `rm` has its own check, covering `-r`, `-R`, combined flags, `--recursive` and an end-of-options `--`. It catches `/`, `/*`, whole-home targets such as `~`, `$HOME` and `${HOME}`, and protected system folders and their contents. Ordinary build-folder cleanup stays allowed. `/etc-backup/` isn't `/etc`, so its name alone doesn't trigger this guard. A Bash allowance or session approval cannot waive this check; `--yolo` disables the default guard. ## Permission modes ```bash luxe --permission-mode strict ``` | Mode | | | --- | --- | | `default` | The defaults above | | `strict` | Ask before every change: `write`, `edit`, `bash`, `git`, `debug`, a `test` with its own `command`, and an applied `ast_grep` or `lsp_navigation` change | | `read-only` | Deny every change, and a `test` with its own `command`. Reading, search, code intelligence and detected tests still work. Also `--read-only`. | | `yolo` | Allow everything, guardrails off. Also `--yolo`. Your own `deny` rules still apply. | [Plan mode](agent.md#plan-then-build) is a different thing: the session's mode, not a permission mode. It refuses every change but a write to the plan file, before the permission rules are asked, so a rule that allows a command does not let plan mode run it. A [review](agent.md#a-second-opinion) runs in a mode that refuses every change. ## Rules ```json title="~/.config/luxe/permissions.json" { "mode": "default", "permission": { "bash": { "*": "allow", "rm *": "ask", "git push * --force *": "deny" }, "git": { "commit*": "allow" }, "read": { "*": "allow", "*.env": "deny", "*.env.example": "allow" }, "external_directory": { "~/scratch/**": "allow" }, "mcp": { "github/*": "ask" } } } ``` - **Keys** are tool names (`bash`, `read`, `edit`, which also covers `write`, `glob`, `grep`, `git`, `webfetch`, `websearch`, `screenshot`, `mcp`), plus `external_directory` for paths outside the project, `doom_loop` for repeated calls, and `hook_ask` for [hooks](hooks.md) that ask. - **Values** are `allow`, `ask` or `deny`, or a map from pattern to one of them. - **Patterns**: `*` matches anything, `?` one character, and `~` your home folder. `git *` also matches `git` alone. A `bash` pattern matches the command; a `read` or `edit` pattern, the path; an `mcp` pattern, `server/tool`. - **The most specific pattern wins.** Bash chains are also checked segment by segment. An allowance for `git *` does not allow `git status && curl example.com`: the `curl` segment needs its own allowance. A denied segment blocks the call. Otherwise, if any segment asks, the call asks too. Every segment must be allowed for the whole chain to run without asking. The matcher is best-effort, not a shell parser. Tools that run a program, change files or search a folder are judged by the same rules: | Tool | Also checked as | | --- | --- | | `test` with a `command` | `bash`, segment by segment, with its `filter`; a filter that hides a second command is checked too | | `debug` `launch` | `bash`, as the program and its arguments; a shell's `-c` script is checked as a command of its own | | `ast_grep` replace with `apply: true` | `edit`, for each path | | `lsp_navigation` rename or code action with `apply: true` | `edit`, for the file | | `grep`, `glob`, `ast_grep` | `read`, for the folder or file searched | | `module_report`, `read_symbol`, `lsp_navigation`, `lsp_diagnostics` | `read`, for each file opened | Each tool's own rules still apply alongside. A folder outside the project asks, and a named `.env` is denied even with `all: true`. **Always** on these prompts remembers only what was asked: the exact command for `test` and `debug`, the folder for one search outside the project, and nothing for an applied change, since its files aren't known until it runs. `grep` and `ast_grep` also check every file they find before reading it, so a search of the whole project skips a nested `.env`; `glob` checks every name before listing it. A file your rules deny is skipped. One that would ask is read when the run approves asks on its own (headless by default, or with auto-approve on) and skipped otherwise, since a search can't stop to ask. The result says how many were skipped; `read` the file to be asked. A walk over a huge tree stops at 100,000 files and says so. An applied rename or code action is refused whole if the language server's edit names a file outside the project or inside a fetched repository. These checks cover what a tool is asked to open. They don't sandbox a language server, which can index other files and runs the project's own code. A project's `.luxe/permissions.json` can only make your rules stricter, never looser, unless you start Luxe with `--trust-project`. ## The permission prompt When a call needs approval, a prompt opens above the composer: ![A commit waiting for approval, offering to remember git commit*](images/security-permission-prompt.svg) | Choice | Does | | --- | --- | | **Allow once** | Run this call | | **Allow for this session** | Allow calls like it until you quit | | **Allow always** | Write the rule to your `~/.config/luxe/permissions.json` | | **Reject** | Refuse; the agent is told and carries on | The prompt shows the rule an **Allow always** would write: a command family, such as `cargo *`, or the whole folder for a path outside the project. A hook's prompt or a chained Bash command offers only **Allow once** and **Reject**: one command family cannot cover a chain. Choose with the arrows and `enter`, or a number; `esc` rejects. For a moment after it opens, the prompt ignores every key but `esc`, so typing meant for the composer cannot answer it. ## When you step away **Auto-approve** (`/permissions on`, or `--auto-approve`) allows every call that would ask, while your `deny` rules still hold. The statusline says `auto-approve` for as long as it is on. **A run with nobody to answer**, such as `--headless`, `luxe loop` or a phase reviewer, allows every `ask` by default. To refuse them instead, and tell the agent why: ```bash luxe --headless --on-ask deny -p "…" ``` or `"agent": { "on_ask": "deny" }` in `config.json`. A project can set `deny` but not `allow`. **Four GitHub actions need a person**: merging a pull request, closing one, submitting a review, and closing an issue. Auto-approve and unattended runs refuse them. To let an unattended run do one, allow it in your own rules, such as `"bash": { "gh pr merge *": "allow" }`. ## Secrets - Credentials are stored in `~/.config/luxe/auth.json`, readable only by you. Luxe warns if `auth.json` or `models.json` can be read by others. - A key is found in this order: `--api-key` or `LUXE_API_KEY`, then what you signed in with, then the provider's own variable, then a provider block in `auth.json` or `models.json`. For a provider Luxe does not know, the block comes before `OPENAI_API_KEY`. The order is the same for the provider a session starts on and for one you switch to in `/model`. - A key can be `$VAR` or `!command` rather than the value, so it never sits in a file. A command runs when a key is needed, so treat these files like your shell's startup file. It runs from your home folder and is stopped after 30 seconds, so a password manager that asks for a passphrase needs a graphical prompt. - Anything that looks like a secret (API keys, tokens, private keys, passwords in URLs) is removed from tool output before the model, the transcript, hooks or the log see it. So is every key and token Luxe itself has used or loaded, whatever it looks like, including `$VAR` and `!command` keys and MCP tokens, as long as it is at least 8 characters. - MCP sign-in tokens are kept in `~/.local/state/luxe/mcp-auth.json`, outside your config folder, so a dotfiles repository never carries them. - An MCP server's `auth` token or OAuth sign-in only travels over `https`, or plain `http` to this machine; a server or sign-in endpoint elsewhere on plain `http` is refused. A key you put in a server's `headers` is not checked, so use `https` for one. See [MCP](mcp.md#sign-in). - Anthropic and Google take API keys only, since their terms do not allow their subscriptions in other programs. ## Commands that need root The agent does not run commands as root: `sudo`, `doas`, `su`, `pkexec`, `run0`, and `gsudo` or `runas` on Windows. A command that needs root needs your attention and consent, and typing it into your own terminal is how you give both. So when a step needs one, the agent puts the exact command in a code block, you copy it into another terminal and run it, then tell the agent it is done. The same goes for a command that waits on you at a terminal, such as a password, a confirmation or an interactive installer. Commands the agent runs have no terminal at all, so one of those fails at once and says what it wanted, rather than waiting where nobody can answer. To allow it anyway, decide ahead of time in your global file. It only helps where no password is needed, such as a passwordless `sudo` rule for one program: ```json title="~/.config/luxe/permissions.json" { "permission": { "bash": { "sudo pacman *": "allow" } } } ``` `--yolo` allows it too. A project's `.luxe/permissions.json` cannot. ## Screen capture The `screenshot` tool's `url` mode renders a web page and captures only that. Its `fullscreen`, `region` and `window` modes capture whatever is on your screen, and the image goes to your model provider, so each **asks** first, and runs with nobody watching refuse them. To turn screen capture off entirely: ```json title="~/.config/luxe/permissions.json" { "permission": { "screenshot": { "fullscreen": "deny", "region": "deny", "window": "deny" } } } ``` ## Untrusted repositories Opening a repository should not run its code. So, without `--trust-project`: - Its `.luxe/hooks.json` is ignored. - Its `gate.commands` in `.luxe/config.json` are ignored with a warning. The loop uses your global commands instead; a project list needs `--trust-project`. - Its `.luxe/permissions.json` can only tighten your rules. - Its MCP servers, language servers and formatters start only in an interactive session; see [Tools](tools.md#a-server-the-project-installed). Its `AGENTS.md` and skills, including the ones it commits in `.agents/skills/`, still reach the model, because your own project's are the point of the feature. Luxe removes text a reviewer cannot see, comments and invisible Unicode, and leaves out a file it cannot finish checking; see [What Luxe removes](project-context.md#what-luxe-removes). Plain text is still instructions, so read an unfamiliar repository's `AGENTS.md` as you would its `Makefile`. Text from GitHub, such as issues and comments, is filtered the same way and given to the agent as someone else's words, not as instructions. So are MCP servers' descriptions and results. A language server can run project code itself: rust-analyzer runs `build.rs`. Treat opening a repository in Luxe like building it. A repository the agent fetches with `web_fetch` is different: you never chose to open it. Luxe clones it into `.luxe/gh-cache/` with symbolic links flattened to plain files, and starts no language server or formatter there; see [Tools](tools.md#github-repositories). ### Isolation Rules are a policy, not a sandbox. For hard isolation, run Luxe in a container that mounts only the project: ```bash docker run --rm -it -v "$PWD":/work -w /work -e ANTHROPIC_API_KEY \ luxe --headless -p "…" ``` Do not mount your home folder or SSH keys. `--network none` works for everything but the model provider, the web tools, the catalog refresh and the update check. ## What Luxe sends over the network There is no telemetry. Luxe connects only to: 1. The model provider you configured, including the cache refreshes described in [Keeping the cache warm](sessions.md#keeping-the-cache-warm) (`"agent": { "cache_warming": "off" }` stops them). For GitHub Copilot that includes reading your plan's usage from GitHub every few minutes while you work (`LUXE_COPILOT_PLAN_USAGE=0` stops it). For an Azure resource it includes listing what the resource has deployed when you open `/model` (`"discover_deployments": false` on the provider stops it) 2. Search providers and web pages, when the agent uses `web_search` or `web_fetch`, and GitHub when `web_fetch` clones a repository (`"tools": { "clone_repositories": false }` stops it) 3. The models.dev catalog, to refresh model details (`LUXE_MODELS_DEV_REFRESH=0` stops it) 4. The update check, once a day (`"update": { "check": false }` stops it) 5. GitHub, through your own `gh`, when you, the agent or an [RPC client](rpc.md#github-writes) work with pull requests and issues. A client can write there only if you start it with `--github-writes`. None of them carries anything about you or your machine. The update check sends no version, platform or identifier; it downloads a public file and compares versions locally. One local report stays on your machine: inside a [herdr](configuration.md#herdr) pane, Luxe runs herdr's own command to tell it whether the agent is working, idle or waiting on you, and the id of the session for herdr to resume. Nothing is sent outside herdr, and `"herdr": false` stops it. What you post to GitHub is yours alone: reviews, replies and pull requests are posted from a preview, with nothing added, and no agent tool can post them. ## Supply chain Luxe is written in Rust with `unsafe` code denied throughout, apart from two reviewed blocks in what ships: one starts each command in a session of its own, so nothing the agent runs can reach your terminal, and one on Windows keeps the programs Luxe starts off the stdin `--rpc` reads. CI checks every dependency's security advisories and license with `cargo-deny`, builds with `--locked`, and pins its GitHub Actions to exact commits. --- # Command line > Every luxe command and flag. `luxe` with no command opens the interface. `luxe --help`, and `--help` after any command, prints the same reference. `luxe --version` prints the version. ## Starting a session | Flag | Does | | --- | --- | | `-c`, `--continue` | Resume the most recent session in this project | | `--resume ` | Resume a session by ID; see `luxe sessions list` | | `--from-pr ` | Resume the session that worked on a pull request | | `--issue ` | Start on a GitHub issue, in a worktree of its own | | `--pr ` | Check out a pull request in a worktree and start a session there | | `--worktree []` | Work in a worktree of its own, on a new or named branch | | `--plan` | Start in [plan mode](agent.md#plan-then-build): read, ask, and write only the plan | See [Git and GitHub](github.md) and [Sessions and context](sessions.md). ## Model | Flag | Does | | --- | --- | | `--model ` | The model, as an ID or `provider/id`, which also picks the provider | | `--provider ` | The provider | | `--thinking ` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` or `ultra`. An unknown level is ignored with a warning. | | `--api-key ` | The key, or `$VAR`, or `!command`. A key typed here is visible to other users of the machine, so prefer the other two. | | `--base-url ` | The endpoint, including its version, such as `/v1` | These win over the environment and your config for this run only. See [Models and providers](providers.md). ## Permissions and tools | Flag | Does | | --- | --- | | `--permission-mode ` | `default`, `strict`, `read-only` or `yolo` | | `--read-only` | The same as `--permission-mode read-only` | | `--yolo` | Allow everything, with the guardrails off | | `--auto-approve` | Start with every prompt approved. Interface and `--rpc` only. | | `--on-ask ` | What a prompt becomes with nobody to answer: `allow` by default. Headless and loop only. | | `--trust-project` | Let a project's hooks, permission rules and servers apply | | `--no-tool ` | Turn a tool off for this run | | `--with-tool ` | Turn on a tool your config turned off | An unknown `--permission-mode` stops the run with an error. It never falls back to a more permissive mode. See [Security and permissions](security.md) and [Tools](tools.md). ## Without the interface | Flag | Does | | --- | --- | | `--headless` | Run one prompt and exit | | `-p`, `--prompt ` | The prompt, for `--headless` | | `--format ` | `text` streams the reply to stdout and tool activity to stderr. `json` writes one event per line, with token usage. | | `--rpc` | Speak the [RPC protocol](rpc.md) on stdin and stdout | | `--github-writes` | Let an `--rpc` client publish to GitHub: comments, reviews, thread answers, and opening, merging or restacking pull requests. Refused under `--read-only`. | ```bash luxe --headless -p "list the TODO comments in src/" --format text ``` A headless run needs `-p` and a model: `--model`, `LUXE_MODEL`, your last choice or `default_model` in `models.json`. It refuses to start without either. It exits with status 1 when the turn fails or Luxe stops before the turn ends, so a script can tell a failure from an answer without reading the output. ## Commands | Command | Does | | --- | --- | | `luxe auth login [provider]` | Sign in. `--method api-key` or `subscription` when a provider has both, `--stdin` to read a key from a pipe, `--api-key ` to store one given here, `--no-verify` to skip checking it. | | `luxe auth list` | Every provider and how it is configured | | `luxe auth status [provider]` | A credential's type, where it came from, and when it expires | | `luxe auth logout ` | Remove a stored credential | | `luxe models list` | Models you can use. `--provider ` filters, `--configured` lists only the models `models.json` or `auth.json` names. | | `luxe models show ` | What Luxe knows about a model, and where each value came from. A bare ID uses the active provider. | | `luxe models refresh` | Refresh the model catalog. `--force` even if it is fresh. | | `luxe sessions list` | Sessions in this project, newest first | | `luxe mcp list` | Configured MCP servers | | `luxe mcp add --id --name ` | Add a server to the project's `.luxe/mcp.json`, with `--command " "` or `--url `. It counts as a [project's server](mcp.md#servers-a-project-brings). | | `luxe mcp remove --id ` | Remove a server from the project's `.luxe/mcp.json` | | `luxe mcp tools` | Every server's tools | | `luxe mcp login --id ` | Sign in to a server, in a browser | | `luxe loop start` | Start or continue an [autonomous run](loop.md). `--max-turns`, `--compact-percent`, `--pause-percent`, `--quiet`. | | `luxe loop status` | The run's iteration, phase and cost | | `luxe loop pause`, `luxe loop resume`, `luxe loop stop` | Pause, clear a pause, or stop the run | | `luxe loop phase` | The current phase. `--set ` changes it. | | `luxe loop fresh` | Reset the run's turn count; see [Autonomous runs](loop.md#start-a-run) | | `luxe docs [page]` | Read this guide, offline and for the installed version. `--search ` finds lines across every page. | | `luxe doctor` | Check which tools, language servers and debug adapters work | | `luxe init` | Set up a theme, a provider, the tools and the start screen, and write them to `~/.config/luxe/config.json`. `--defaults` skips the questions, `--force` starts from an empty file; see [Editing](configuration.md#editing). | | `luxe wire ` | Summarize a `LUXE_WIRE_LOG` capture; see [Troubleshooting](troubleshooting.md#see-what-is-sent-to-the-model). `--tools` breaks the tool schemas' size down per tool. | Every command also takes the model and permission flags above. --- # Environment variables > The environment variables you can set, and the ones Luxe sets. An environment variable wins over the same setting in a config file, and a command-line flag wins over both. A variable that turns something off, such as `LUXE_HERDR=0`, cannot turn back on what a config file turned off. ## Model and provider | Variable | Default | Does | | --- | --- | --- | | `LUXE_MODEL` | | The model, as an ID or `provider/id` | | `LUXE_PROVIDER` | | The provider | | `LUXE_API_KEY` | | A key for the provider the session starts on | | `LUXE_BASE_URL` | | That provider's endpoint, including its version | | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, … | | Each provider's own key; see [Models and providers](providers.md#providers) | | `LUXE_STREAM_START_TIMEOUT` | `60` | Seconds to wait for a reply to start. `0` waits forever. | | `LUXE_STREAM_IDLE_TIMEOUT` | `180` | Seconds a reply may go quiet. `0` waits forever. | | `LUXE_PROVIDER_RETRIES` | `3` | Tries after the first when a provider fails before answering | | `LUXE_COPILOT_PLAN_USAGE` | `1` | `0` stops reading how much of your GitHub Copilot plan is used | ## Files | Variable | Does | | --- | --- | | `LUXE_CONFIG_DIR` | Where configuration and credentials live, instead of `~/.config/luxe` | | `LUXE_STATE_DIR` | Where Luxe keeps its state, such as the model you last chose, MCP sign-ins and the logs of commands that only report, instead of `~/.local/state/luxe` | | `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `XDG_CACHE_HOME` | Honored on every platform; see [Install](install.md#where-luxe-keeps-files) | When Luxe works out where its files go, a path in one of these, or in `HOME`, that is not valid UTF-8 is ignored, as if the variable were unset. ## Model catalog | Variable | Default | Does | | --- | --- | --- | | `LUXE_MODELS_DEV_REFRESH` | | `0` never refreshes, and the built-in catalog still works. `1` refreshes at every start. Unset refreshes once the copy is older than `LUXE_MODELS_DEV_TTL`. | | `LUXE_MODELS_DEV_URL` | `https://models.dev/api.json` | Refresh from a mirror, as a full URL | | `LUXE_MODELS_DEV_PATH` | | Keep the catalog in this file. The refresh writes there too, so pair it with `LUXE_MODELS_DEV_REFRESH=0` to pin a file of your own. | | `LUXE_MODELS_DEV_TTL` | `21600` | Seconds between refreshes | ## Tools and turns | Variable | Default | Does | | --- | --- | --- | | `LUXE_TOOL_TIMEOUT_SECS` | | Replace the time limit of every tool that has one | | `LUXE_MAX_TOOL_ITERATIONS` | | Most rounds of tool calls in one turn. `0` lifts a configured cap. | | `LUXE_COMPRESS` | `1` | `0` stops trimming long command output, though output past the size limit is still cut | | `LUXE_LSP_ON_EDIT` | `1` | `0` stops adding language server errors, and notes about missing tests, after edits | | `LUXE_SIZE_CHECK` | `1` | `0` stops telling the agent when an edit takes a file past 500 lines or a function past 20 | | `LUXE_DEBUG_NUDGE` | `1` | `0` stops suggesting the debugger after repeated test failures | | `LUXE_PLAN_NUDGE` | `1` | `0` stops reminding the agent to update its checklist | | `LUXE_LAZY_TOOLS` | `1` | `0` sends every tool's description with every request | | `LUXE_CORE_TOOLS` | | The tools sent with every request, comma-separated; the rest load when needed | | `LUXE_DAP_STOP_TIMEOUT_SECS` | `30` | How long the debugger waits for a program to stop | | `LUXE_MAX_DEPTH` | `2` | How deeply Luxe may run inside itself, as with [subagents](recipes.md#subagents) | | `LUXE_WEB_ALLOW_PRIVATE` | | `1` lets `web_fetch` and `screenshot` reach your local network and files | | `LUXE_WEB_TIMEOUT_SECS` | `30` | How long one fetch may take. Past 60, raise `LUXE_TOOL_TIMEOUT_SECS` too. | | `BRAVE_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY`, `FIRECRAWL_API_KEY` | | Search providers, and Firecrawl for pages built with JavaScript; see [Tools](tools.md#web) | ## Integrations | Variable | Does | | --- | --- | | `LUXE_EDITOR`, then `VISUAL`, then `EDITOR` | The editor Luxe opens files in | | `LUXE_FILE_MANAGER`, then `FILE_MANAGER` | The file manager `O` in the dock's file tree opens a folder in. Without either, the first of yazi, lf, ranger and nnn that is installed, then your desktop's own. | | `LUXE_HERDR` | `0` stops reporting state to [Herdr](recipes.md#run-luxe-in-herdr) | | `LUXE_UPDATE_CHECK` | `0` turns off the update check | ## Diagnostics | Variable | Does | | --- | --- | | `LUXE_LOG` | What goes in the log, such as `debug` or `luxe_core=debug`. `RUST_LOG` works too. See [Logs](troubleshooting.md#logs). | | `LUXE_WIRE_LOG` | A file to record every request sent to the model; see [Troubleshooting](troubleshooting.md#see-what-is-sent-to-the-model) | ## Set by Luxe | Variable | Is | | --- | --- | | `LUXE_DEPTH` | How deeply this run is nested inside another Luxe | | `LUXE_REVIEW_GATE_ACTIVE` | Set for a loop's [gate commands](loop.md#check-each-phase), so a Luxe they start does not run a gate of its own | | `LUXE_HANDOFF` | For a [launcher](configuration.md#launchers): a file whose contents go in the composer when the program exits | --- # Troubleshooting > Common problems, where Luxe logs, and what to include in a bug report. ## Start with `luxe doctor` ```bash luxe doctor ``` It lists the tools, language servers and debug adapters Luxe found, starts each language server to check that it works, and shows how to install or fix what does not. ## Common problems | Problem | Fix | | --- | --- | | **No model selected**, or `--headless` refuses to start | Choose one with `/model`, or pass `--model provider/id`. Luxe never picks one for you. | | The model picker is empty | No provider is set up. Open **Configuration** (`c`), or set a provider's key in the environment. | | A provider keeps failing with `retrying (3/3)` | It is overloaded or unreachable. See [When a provider stalls](providers.md#when-a-provider-stalls) for the time limits. | | A setting does nothing | Luxe ignores keys it does not know, so check the spelling. `luxe init` and `/settings` write a `config.schema.json` your editor can check against. A project's file cannot change some settings; see [Configuration](configuration.md#what-a-project-cannot-change). | | Icons show as boxes, or sit flush against their words | See [Fonts](install.md#fonts), and the `glyphs` setting in [Appearance](appearance.md#icons-and-fonts). | | Your terminal's copy key does nothing | The terminal keeps it. Hold `shift` while dragging, or let the key pass through; see [Mouse and clipboard](interface.md#mouse-and-clipboard). | | Images do not appear | Your terminal may not draw them. `/images` says why; see [Images](appearance.md#images). | | A language server is not used | `luxe doctor` says whether it starts. A server a project installed is used only when you are watching; see [Tools](tools.md#a-server-the-project-installed). | | A project's hooks or MCP servers do not run | They need `--trust-project`, or an interactive session for MCP servers. See [Security](security.md#untrusted-repositories). | | Luxe opened the wrong folder as the project | Inside a repository, a `.luxe/` between you and its top scopes the project there. Remove it, or start Luxe somewhere else. See [Where the project begins](project-context.md#where-the-project-begins). | | `/pr` or `/github` says GitHub is unavailable | Install the GitHub CLI and sign in: `gh auth login`. A private repository also needs `gh auth setup-git`. | | A key binding does not work | Your terminal may not send that key. Rebind it; see [Commands and keys](commands.md#rebind-keys). | | On a Mac, some `alt` keys type a character such as `ø` or `π` | Your terminal treats Option as a key that types characters. Set it to act as Alt; see [Alt keys on a Mac](commands.md#alt-keys-on-a-mac). | ## Logs A session logs to `.luxe/logs/luxe.log` in the project. Commands that only report, such as `luxe auth`, `luxe doctor` and `luxe models`, log to `logs/luxe.log` in the state directory: `~/.local/state/luxe/` (`%LOCALAPPDATA%\luxe\` on Windows), or wherever `LUXE_STATE_DIR` or `XDG_STATE_HOME` puts it. On macOS and Linux, a log is readable only by you. One past 5 MB is moved to `luxe.log.1` the next time Luxe starts, replacing the one before. The log shows Luxe's own activity at `info` and everything else at `warn`. For more: ```bash LUXE_LOG=debug luxe # everything LUXE_LOG=luxe_core::hooks=debug luxe # one part, in detail ``` ## See what is sent to the model `LUXE_WIRE_LOG` records every request Luxe sends and every reply, with credentials removed. `luxe wire` summarizes it: what fills each request, whether the cached part stays the same between turns, and whether anything is sent twice. ```bash LUXE_WIRE_LOG=/tmp/wire.jsonl luxe --headless -p "what does src/main.rs do?" luxe wire /tmp/wire.jsonl luxe wire /tmp/wire.jsonl --tools # with each tool's share ``` The log stays on your machine and is off unless the variable is set. ## Report a bug Open an issue at [github.com/evanwhittaker/luxe](https://github.com/evanwhittaker/luxe/issues) with: - `luxe --version`, your operating system and terminal - What you did, what you expected, and what happened - The relevant part of `.luxe/logs/luxe.log` If the interface itself draws wrongly, such as stray characters or a half-drawn screen, record the raw output, which shows what Luxe actually wrote: ```bash script -f /tmp/luxe.tty -c luxe # replay with `cat` or `scriptreplay` asciinema rec /tmp/luxe.cast -c luxe ``` Attach the recording and the log from the same time.