# Maki Docs
Maki is a terminal coding agent written in Rust, built bottom up to spend as few tokens as possible without getting dumber. Point it at a repo, pick a provider, and it reads, searches, edits, and runs code for you.
The docs are sorted by what you came here to do:
Getting Started
new to maki
Guides
getting things done
Reference
looking something up
ToolsEvery built-in tool and its parameters.
ProvidersModel catalogs, env vars, providers.toml, model tiers.
PermissionsWhat runs freely, what asks first, TOML rules.
Folder TrustWhether a project's .maki config may run on your machine.
NotificationsKnow when a session finishes or needs your input.
MCPExternal tool servers over stdio or HTTP.
CommandsThe / palette, sessions, toggles, custom commands.
KeybindingsDefaults, precedence, rebinding from Lua.
Lua APIThe plugin surface, mirrored from Neovim.
HooksRewrite, block, or trim a tool call from Lua.
Lua PackagesLoad external Lua plugins from Neovim-style package directories.
CLIFlags and subcommands (auth, models, acp, prompt, ...).
TelemetryOpt-in OpenTelemetry metrics and events, to a collector you run.
Something missing or wrong? Open an issue on [GitHub](https://github.com/tontinton/maki).
---
# Quick Start
Install Maki, connect a provider, run a first session. A few minutes, start to finish.
## Install
### Linux / macOS
```sh
# Download and read the script first (don't blindly trust shell scripts).
curl -fsSL https://maki.sh/install.sh -o install.sh
cat install.sh
# Then run.
chmod +x install.sh && sh install.sh
```
One-liner:
```sh
curl -fsSL https://maki.sh/install.sh | sh
```
Installs to `~/.local/bin`. Override with `MAKI_INSTALL_DIR`.
### Windows (PowerShell)
```powershell
# Download and read the script first (don't blindly trust remote scripts).
irm https://maki.sh/install.ps1 -OutFile install.ps1
Get-Content install.ps1
# Then run.
.\install.ps1
```
One-liner:
```powershell
irm https://maki.sh/install.ps1 | iex
```
### Windows (Git Bash)
```sh
curl -fsSL https://maki.sh/install.sh | sh
```
Both install to `%LOCALAPPDATA%\maki` and add it to your user PATH. Override with `MAKI_INSTALL_DIR` / `$env:MAKI_INSTALL_DIR`.
### Living on the edge (main branch)
```sh
cargo install --locked --git https://github.com/tontinton/maki.git maki
```
### With Nix
```sh
nix run github:tontinton/maki
```
Or download a pre-built binary from [GitHub Releases](https://github.com/tontinton/maki/releases/latest).
## Connect a provider
```bash
maki auth login # interactive picker (OAuth or API key)
export ANTHROPIC_API_KEY=... # or just export a key
```
Anthropic, OpenAI, Google, Ollama, and friends all work; multiple keys in one var rotate on rate limits. Every env var and model catalog is in [Providers](/docs/providers/).
## First session
From a repo:
```bash
maki
```
Type what you want done, press Enter, watch it work. Worth knowing on day one:
- **Permissions.** File edits inside the repo run freely. `bash` and web tools ask first: `y` allows once, `s` for the session, `a` for the project. Deny rules always win; `/yolo` skips the prompts. Details in [Permissions](/docs/permissions/).
- **Plan mode.** `Tab` toggles it. The agent may only write the plan file until you approve, then back to build mode.
- **Models.** `/model` switches mid-session.
- **Sessions.** `/new` starts a second session while the first keeps working in the background; `/sessions` jumps between them. Tomorrow, `maki --continue` resumes where you left off.
- **Your shell.** Prefix input with `!` to run a command yourself (`!cargo test`). `!!` hides command and output from the agent.
- **Escape hatch.** `Esc Esc` cancels a streaming response. When idle, it rewinds instead.
- **Help.** `Ctrl+H` lists every keybinding, or see [Keybindings](/docs/keybindings/).
## Default model (optional)
```lua
-- ~/.config/maki/init.lua
maki.setup({
provider = {
default_model = "anthropic/claude-sonnet-4-6",
},
})
```
Without it, Maki remembers the last model you used.
## Teach it your project
Maki loads `AGENTS.md` (or `CLAUDE.md`, `.cursorrules`, and friends) from your repo automatically. Per-project settings live under `.maki/`:
```
.maki/
├── init.lua # overrides global config
├── permissions.toml # permission rules
├── mcp.toml # MCP server config
├── commands/ # custom slash commands (.md files)
└── skills/ # project skills (each dir has a SKILL.md)
AGENTS.md # always in context
AGENTS.local.md # personal per-project instructions (gitignored)
```
A project `.maki` directory can run code, so Maki asks once per folder before
loading it. `AGENTS.md`, commands and skills load either way. See
[Folder Trust](/docs/folder-trust/).
Which instruction file wins, when subdirectory rules load, and how skills and memory fit together: [Context](/docs/context/). All settings: [Configuration](/docs/configuration/).
---
# Configuration
Settings go in `init.lua`, a Lua script that calls `maki.setup()`. Same language as plugins.
Two places, both optional:
- **Global**: `~/.config/maki/init.lua`
- **Project**: `.maki/init.lua` in the active Git checkout, or in the working
directory outside Git
When both exist, project settings override global ones. Neither file is required.
A project `init.lua` runs only once you trust that folder, see
[Folder Trust](/docs/folder-trust/).
## Example
```lua
maki.setup({
ui = {
splash_animation = true,
mouse_scroll_lines = 5,
theme = "tokyonight",
tool_output_lines = {
bash = 8,
read = 5,
},
},
agent = {
max_output_lines = 3000,
},
provider = {
default_model = "anthropic/claude-sonnet-4-6",
allowed_models = { "anthropic/*", "openai/gpt-5" },
excluded_models = { "*/*-preview" },
},
storage = {
max_log_files = 5,
},
plugins = {
bash = { timeout_secs = 180 },
index = { max_file_size_mb = 4 },
},
})
```
All fields are optional. Typos in field names cause an error right away.
`provider.allowed_models` is a list of glob patterns for qualified `provider/model-id` specs. `*` also matches `/`, so `opencode/*` includes nested model IDs. When the list is empty or omitted, every model is allowed. `provider.excluded_models` removes matching models after that, so exclusions always win. A project list replaces the matching global list; omit it to inherit or use `{}` to clear it. The policy applies to selectors, CLI and API model changes, delegation, and `maki models`.
`maki.setup()` can only be called once per init.lua.
## Full Reference
### Top-level
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `always_yolo` | bool | `false` | Start every session with YOLO mode (skip permission prompts, deny rules still apply) |
| `always_fast` | bool | `false` | Start every session with fast mode (Anthropic Opus or eligible Codex subscription models, ignored elsewhere) |
| `always_workflow` | bool | `false` | Start every session with workflow mode (task callable inside code_execution) |
| `always_thinking` | bool \| string | `false` | Start every session with extended thinking (true/"adaptive", "off", an effort level ("minimal" to "max"), or a token budget) |
### `ui`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `splash_animation` | bool | `true` | - | Show splash animation on startup |
| `scrollbar` | bool | `true` | - | Show vertical scrollbar in scrollable areas |
| `inline_images` | bool | `true` | - | Render inline images in terminals with graphics support, falling back to an [image] line where nothing else names the image |
| `notifications` | string | `auto` | - | Terminal notification method: auto, osc9, bell, or off |
| `flash_duration_ms` | u64 | `1500` | - | Duration of flash messages (ms) |
| `typewriter_ms_per_char` | u64 | `4` | - | Typewriter effect speed (ms/char) |
| `mouse_scroll_lines` | u32 | `3` | 1 | Lines per mouse wheel scroll |
| `max_input_lines` | u32 | `20` | 1 | Maximum visible input lines |
| `show_thinking` | bool | `true` | - | When true (default), show full model reasoning live and persisted. When false, hide reasoning behind an indicator (thinking> ...) with a click-to-expand hint, both while thinking and after it completes |
| `clock_format` | String | `system` | - | Clock format for timestamps: "12h", "24h", or "system" (follow the OS preference, 24h when unknown) |
### `ui.theme`
Name of the color theme to load at startup, overriding the theme you last picked interactively. If unset, Maki keeps your last selection (the built-in default on first run). An unknown name is ignored with a warning.
Available themes: `ayu_dark`, `ayu_light`, `ayu_mirage`, `carbonfox`, `catppuccin_frappe`, `catppuccin_latte`, `catppuccin_macchiato`, `catppuccin_mocha`, `dark_daltonized`, `dracula`, `everforest_dark`, `fleet_dark`, `github_dark`, `gruvbox`, `gruvbox_light`, `kanagawa`, `kanagawa_ink`, `kanagawa_plum`, `material_darker`, `monokai_pro`, `night_owl`, `nightfox`, `nord`, `onedark`, `rose_pine`, `rose_pine_dawn`, `rose_pine_midnight`, `rose_pine_moon`, `solarized_dark`, `solarized_light`, `tokyonight`, `vscode_dark_plus`, `zenburn`.
You can add your own themes too. Drop a `.toml` file into `themes/` inside your Maki config directory, for example `~/.config/maki/themes/`. If it reuses a built-in name, yours wins.
Diff signs use `diff_old_sign` and `diff_new_sign`, which default to `diff_old` and `diff_new`. These styles are applied after `code_block`, so their properties take precedence. Diff gutters use `diff_old_line_nr` and `diff_new_line_nr`, which default to `diff_line_nr`.
Themes use 24-bit colors by default, but not every terminal can show them. Maki checks the environment, terminfo, and the terminal itself, and when truecolor is missing it quietly falls back to the closest of the 256 classic terminal colors. If detection gets it wrong, set `MAKI_TRUECOLOR=1` to force truecolor or `MAKI_TRUECOLOR=0` to force the fallback.
Theme files can also name terminal colors instead of giving hex values, using the same names as Helix: `default`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `gray`, `light-red`, `light-green`, `light-yellow`, `light-blue`, `light-magenta`, `light-cyan`, `light-gray`, and `white`. Write them exactly as listed. `lightgray`, `light_gray` and `LIGHT-GRAY` are all rejected. `default` means the terminal default. Maki also takes a number from `0` to `255` to pick a palette entry by index, which Helix does not.
These work everywhere a hex value does, including syntax highlighting scopes, so a theme can be written entirely against the palette your terminal already defines. Maki passes them through as palette references rather than resolving them to RGB, so the colors stay correct in terminals that mangle truecolor, such as nested tmux over ssh.
### `ui.tool_output_lines`
How many lines of output to show per tool in the UI. All values are `usize` with a minimum of 1.
| Field | Default |
|-------|---------|
| `bash` | 5 |
| `code_execution` | 5 |
| `task` | 5 |
| `index` | 3 |
| `grep` | 3 |
| `read` | 3 |
| `write` | 7 |
| `web` | 3 |
| `other` | 3 |
### `agent`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `max_output_bytes` | usize | `51200` | 1024 | Max tool output size (bytes) |
| `max_output_lines` | usize | `2000` | 10 | Max tool output lines |
| `max_continuation_turns` | u32 | `3` | 1 | Max automatic continuation turns |
| `max_turn_output` | u32 | `32768` | 1024 | Output tokens one turn asks for, raised where an effort level needs the room and capped by the model's own limit |
| `compaction_buffer` | u32 \| string | `20%` | - | Context reserved for compaction: token count or percent of the context window (e.g. "20%") |
| `compaction_instructions` | String | `none` | - | Extra instructions appended to the compaction summary prompt |
| `post_compaction_instructions` | String | `none` | - | Extra instructions the agent receives after any compaction (e.g. re-read plan.md) |
| `stale_read_check` | bool | `true` | - | Require re-reading a file that changed on disk before editing it |
| `rtk` | bool | `true` | - | Rewrite bash commands with [rtk](https://github.com/rtk-ai/rtk) when it is installed |
### `provider`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `default_model` | String | `none` | - | Default model identifier (e.g. `anthropic/claude-sonnet-4-6`) |
| `allowed_models` | string[] | `[]` | - | Glob patterns for permitted qualified model specs; empty permits all models |
| `excluded_models` | string[] | `[]` | - | Glob patterns for excluded qualified model specs; exclusions take precedence |
| `connect_timeout_secs` | u64 | `10` | 1 | HTTP connect timeout (seconds) |
| `low_speed_timeout_secs` | u64 | `120` | 1 | Low speed timeout (seconds with less than 1 byte received) |
| `stream_timeout_secs` | u64 | `300` | 10 | Streaming response timeout (seconds) |
| `retry_base_ms` | u64 | `2000` | 1 | Base delay between retries (milliseconds, grows per attempt) |
| `retry_max_ms` | u64 | `60000` | 1 | Cap on the guessed retry backoff (milliseconds) |
| `max_retries` | u32 | `5` | - | Max retries on a rate limit the server sent no Retry-After for, 0 to never retry them |
| `max_timeout_retries` | u32 | `10` | - | Max retries on stream timeouts |
### `storage`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `max_log_bytes_mb` | u64 | `200` | 1 | Max total log size (MB) |
| `max_log_files` | u32 | `10` | 1 | Max number of log files to keep |
| `input_history_size` | usize | `100` | 10 | Number of input history entries to retain |
### `net`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `allowed_private_hosts` | string[] | `[]` | Hosts allowed to resolve to a private or loopback address, as `host`, `host:port`, or a CIDR range. Plain `http://` is kept for them instead of being upgraded to `https://` |
`maki.net` refuses private, loopback and metadata addresses, because the model picks the URLs. List a host here to let it through:
```lua
maki.setup({
net = {
allowed_private_hosts = { "localhost:8080", "nas.lan", "10.0.0.0/8" },
},
})
```
An entry with no port covers every port. A name you list is allowed whatever it resolves to. A name you did not list stays blocked when DNS lands it on a private address, unless that address falls in a range you allowed, so keep ranges as small as the service needs. Every redirect hop is checked against the same list. [Permissions](/docs/permissions/#network-addresses) covers what the guard protects.
### `trust`
Answers the folder trust question in advance. Read from the global `~/.config/maki/init.lua` only, since a project file that could set it would be trusting itself:
```lua
maki.setup({
trust = {
paths = { "~/src/me/*", "/workspace" },
prompt = false,
},
})
```
`paths` is a list of globs matched against the project root, empty by default. `prompt` is a bool, `true` by default. Setting it to `false` drops the startup card and leaves the folder restricted unless a `paths` entry matches. [Folder Trust](/docs/folder-trust/#trust-policy) covers glob syntax and which run modes apply the policy.
### `telemetry`
| Field | Type | Default | Env | Description |
|-------|------|---------|-----|-------------|
| `enabled` | bool | `false` | `MAKI_ENABLE_TELEMETRY` | Master switch |
| `metrics_exporter` | string | `none` | `OTEL_METRICS_EXPORTER` | Where metrics go: `otlp`, `console`, `none`, or a comma-separated mix |
| `logs_exporter` | string | `none` | `OTEL_LOGS_EXPORTER` | Where events go: `otlp`, `console`, `none`, or a comma-separated mix |
| `protocol` | string | `-` | `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol: `grpc`, `http/protobuf`, or `http/json`. Required when an exporter is `otlp` |
| `endpoint` | string | `-` | `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector endpoint. HTTP appends `/v1/metrics` and `/v1/logs` |
| `headers` | table | `{}` | `OTEL_EXPORTER_OTLP_HEADERS` | Extra headers sent with every export |
| `timeout_ms` | integer | `10000` | `OTEL_EXPORTER_OTLP_TIMEOUT` | Per-export request timeout (ms) |
| `compression` | string | `none` | `OTEL_EXPORTER_OTLP_COMPRESSION` | Payload compression: `gzip` or `none` |
| `metrics_protocol` | string | `-` | `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | Metrics-only protocol override |
| `metrics_endpoint` | string | `-` | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Metrics-only endpoint, used verbatim with no path appended |
| `metrics_headers` | table | `{}` | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | Metrics-only headers, merged over `headers` |
| `metrics_timeout_ms` | integer | `-` | `OTEL_EXPORTER_OTLP_METRICS_TIMEOUT` | Metrics-only request timeout (ms) |
| `logs_protocol` | string | `-` | `OTEL_EXPORTER_OTLP_LOGS_PROTOCOL` | Logs-only protocol override |
| `logs_endpoint` | string | `-` | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | Logs-only endpoint, used verbatim with no path appended |
| `logs_headers` | table | `{}` | `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | Logs-only headers, merged over `headers` |
| `logs_timeout_ms` | integer | `-` | `OTEL_EXPORTER_OTLP_LOGS_TIMEOUT` | Logs-only request timeout (ms) |
| `metrics_interval_ms` | integer | `60000` | `OTEL_METRIC_EXPORT_INTERVAL` | How often metrics are exported (ms) |
| `metrics_export_timeout_ms` | integer | `30000` | `OTEL_METRIC_EXPORT_TIMEOUT` | Deadline for one metrics export, retries included (ms) |
| `logs_interval_ms` | integer | `5000` | `OTEL_LOGS_EXPORT_INTERVAL`, `OTEL_BLRP_SCHEDULE_DELAY` | How often queued events are flushed (ms) |
| `logs_max_queue_size` | integer | `2048` | `OTEL_BLRP_MAX_QUEUE_SIZE` | Event queue capacity. Events are dropped and counted when it is full |
| `logs_max_export_batch_size` | integer | `512` | `OTEL_BLRP_MAX_EXPORT_BATCH_SIZE` | Maximum events per export request |
| `logs_export_timeout_ms` | integer | `30000` | `OTEL_BLRP_EXPORT_TIMEOUT` | Deadline for one events export, retries included (ms) |
| `metrics_temporality` | string | `delta` | `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` | Metric temporality: `delta` or `cumulative` |
| `service_name` | string | `maki` | `OTEL_SERVICE_NAME` | `service.name` on the exported resource |
| `resource_attributes` | table | `{}` | `OTEL_RESOURCE_ATTRIBUTES` | Extra resource attributes, your place for team or environment labels |
| `metrics_include_session_id` | bool | `true` | `OTEL_METRICS_INCLUDE_SESSION_ID` | Attach `session.id` to metrics. Turn off to keep metric cardinality low |
| `metrics_include_version` | bool | `false` | `OTEL_METRICS_INCLUDE_VERSION` | Attach `app.version` to metrics |
| `log_user_prompts` | bool | `false` | `OTEL_LOG_USER_PROMPTS` | Include prompt text in `maki.user_prompt` events. Off by default |
| `log_tool_details` | bool | `false` | `OTEL_LOG_TOOL_DETAILS` | Include tool input in `maki.tool_result` events. Off by default |
| `content_max_length` | integer | `10240` | `MAKI_OTEL_CONTENT_MAX_LENGTH` | Character cap on any logged prompt or tool input |
Every field also has an environment variable, shown in the Env column, and the variable wins. See [Telemetry](/docs/telemetry/) for the full picture.
## Plugins
The `plugins` table turns plugins on or off and passes options to them. All bundled plugins are on by default. Set `enabled = false` to turn one off.
A plugin that is off never loads, so its tool name is free for one of your own plugins to take. Permission rules are keyed by the tool name alone, and names such as `bash`, `write`, and `task` already have rules in maki. A plugin that takes one of them inherits those rules, together with any "always allow" you saved. Maki warns you at load when this happens.
Each plugin checks its own options at startup. A typo, a wrong type, or an unknown plugin name gives you a clear error right away.
The edit plugin's extra tools are options too: `plugins.edit = { multiedit = false, insert_lines = true }`. The old `tools` table is gone. If your config still uses it, Maki stops at startup and shows you the new form.
This table is for bundled plugins only. Your own plugins go in `~/.config/maki/lua/`, see [Plugins](/docs/plugins/).
```lua
maki.setup({
plugins = {
bash = { timeout_secs = 180 },
websearch = { enabled = false },
},
})
```
### `plugins.bash`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `max_output_bytes` | integer | - | - | Override `agent.max_output_bytes` for this tool. |
| `max_output_lines` | integer | - | - | Override `agent.max_output_lines` for this tool. |
| `timeout_secs` | integer | `120` | 5 | Kill the command after this many seconds. A call's `timeout` param overrides it. |
### `plugins.code_execution`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `max_memory_mb` | integer | `50` | 10 | Memory limit for the Python sandbox (MB). |
| `max_output_bytes` | integer | - | - | Override `agent.max_output_bytes` for this tool. |
| `max_output_lines` | integer | - | - | Override `agent.max_output_lines` for this tool. |
| `timeout_secs` | integer | `30` | 5 | Script execution time budget in seconds; waiting on tool calls does not count. A call's `timeout` param overrides it. |
### `plugins.edit`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `edit_lines` | boolean | `true` | - | Provide the `edit_lines` tool. |
| `insert_lines` | boolean | `false` | - | Provide the opt-in `insert_lines` tool. |
| `multiedit` | boolean | `true` | - | Provide the `multiedit` tool. |
### `plugins.glob`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `max_output_bytes` | integer | - | - | Override `agent.max_output_bytes` for this tool. |
| `max_output_lines` | integer | - | - | Override `agent.max_output_lines` for this tool. |
| `search_result_limit` | integer | `100` | 10 | Max files returned per search. |
### `plugins.grep`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `max_line_bytes` | integer | `500` | 80 | Skip lines longer than this many bytes. |
| `max_output_bytes` | integer | - | - | Override `agent.max_output_bytes` for this tool. |
| `max_output_lines` | integer | - | - | Override `agent.max_output_lines` for this tool. |
| `search_result_limit` | integer | `100` | 10 | Max match groups per search. A call's `limit` param overrides it. |
### `plugins.index`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `max_file_size_mb` | integer | `2` | 1 | Refuse to index files larger than this many MB. |
### `plugins.read`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `max_line_bytes` | integer | `500` | 80 | Truncate lines longer than this many bytes. |
| `max_output_lines` | integer | - | - | Override `agent.max_output_lines` for this tool. |
### `plugins.skill`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `plugin_dev` | boolean | `true` | - | Offer the builtin maki-plugin-dev skill for writing maki plugins. |
### `plugins.task`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `allow_model` | boolean | `false` | - | Expose a `model` input that overrides the subagent model. Only enable if you trust callers to pick an exact model themselves. |
| `max_concurrent` | integer | `8` | 1 | Max concurrently running subagents. |
### `plugins.webfetch`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `max_output_bytes` | integer | - | - | Override `agent.max_output_bytes` for this tool. |
| `max_output_lines` | integer | - | - | Override `agent.max_output_lines` for this tool. |
| `max_response_bytes` | integer | `5242880` | 1024 | Stop reading a response after this many bytes. |
### `plugins.websearch`
| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `max_output_bytes` | integer | - | - | Override `agent.max_output_bytes` for this tool. |
| `max_output_lines` | integer | - | - | Override `agent.max_output_lines` for this tool. |
| `max_response_bytes` | integer | `5242880` | 1024 | Stop reading a response after this many bytes. |
| `provider` | string | `"exa"` | - | Search backend: "exa" (default) or "youcom" (You.com MCP). |
## Validation
If a value is below its minimum, Maki shows a `ConfigError` with the field name, value, and minimum.
## Directory layout
Maki follows platform directory conventions. On Linux and macOS that is XDG. On Windows, config, data, state, and logs all live under Roaming AppData (Windows has no separate state dir in this layout).
| Purpose | Linux / macOS | Windows |
|---------|---------------|---------|
| Config | `~/.config/maki/` | `%APPDATA%\maki\` |
| Data | `~/.local/share/maki/` | `%APPDATA%\maki\` |
| State | `~/.local/state/maki/` | `%APPDATA%\maki\` |
| Logs | `~/.local/logs/maki/` | `%APPDATA%\maki\` |
| Cache | `~/.cache/maki/` | `%LOCALAPPDATA%\maki\` |
Config holds `init.lua`, `permissions.toml`, `mcp.toml`, `providers.toml`, and `commands/`. State holds sessions, auth tokens, memories, plans, folder trust, and model-tier overrides. The install script puts the binary under `%LOCALAPPDATA%\maki` on Windows; that is separate from these runtime dirs.
`~/.maki/` (or `%USERPROFILE%\.maki\`) is checked as a legacy fallback. If that directory still exists, maki uses it for everything until you migrate.
### Migrating from ~/.maki/
```
maki migrate xdg
```
This safely moves sessions, auth, plans, memories, logs, and preferences to the platform locations above. Where both old and new files exist, they are merged (input history, model tiers, etc.). Nothing is deleted until it has been copied. At the end you get a summary of where everything lives now.
Safe to run more than once.
## Personal Instructions
On top of the project instruction files Maki loads from the git root down to the cwd (`AGENTS.md`, `CLAUDE.md`, and friends; see [Context](/docs/context/#instruction-files)), you can add:
- `AGENTS.local.md` in any of those project directories for per-directory preferences (gitignored)
- `~/.config/maki/AGENTS.md` for preferences that apply to all projects
All of these are added to the system prompt at the start of every session.
## Memory
The `memory` tool and `/memory` command store small Markdown notes under the state directory, scoped per project:
`…/state/maki/projects//memories/`
(Linux/macOS: `~/.local/state/maki/…`; Windows: `%APPDATA%\maki\…`). Use them for non-obvious gotchas and decisions that should survive across sessions. They are separate from skills and from `AGENTS.md`.
Related pages: [Skills](/docs/skills/), [CLI](/docs/cli/), [Providers](/docs/providers/#providers-toml).
---
# Tools
Maki ships with 21 built-in tools in this reference (20 on by default, 1 opt-in via plugin options). Tools marked **opt-in** are off until you enable them under `plugins` in [Configuration](/docs/configuration/).
## File Operations
### `bash` {#bash}
Execute a bash command.
Commands run in by default.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `command` | string | yes | | The bash command to execute |
| `description` | string | no | | Short description (3-5 words) of what the command does |
| `timeout` | integer | no | 120 | Timeout in seconds |
| `workdir` | string | no | cwd | Working directory |
### `list` {#list}
List directory contents. Returns entry names sorted alphabetically, directories first with a trailing /.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `path` | string | yes | Absolute path to the directory |
### `read` {#read}
Read a file. Returns contents with line numbers (1-indexed).
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `limit` | integer | yes | Max number of lines to read. Use 0 to read until end of file (capped at 2000 lines). |
| `offset` | integer | yes | Line number to start from (1-indexed). Use 1 for the first line. |
| `path` | string | yes | Absolute path to the file |
### `write` {#write}
Write content to a file, replacing existing content.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `content` | string | yes | The complete file content to write |
| `path` | string | yes | Absolute path to the file |
### `edit` {#edit}
Replace an exact string match in a file.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `new_string` | string | yes | | Replacement string |
| `old_string` | string | yes | | Exact string to find (must match uniquely unless replace_all is true) |
| `path` | string | yes | | Absolute path to the file |
| `replace_all` | boolean | no | false | Replace all occurrences |
### `multiedit` {#multiedit}
Make multiple find-and-replace edits to a single file atomically.
Prefer this over edit when making multiple changes to the same file.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `edits` | array | yes | Array of edit operations to apply sequentially |
| `path` | string | yes | Absolute path to the file |
### `edit_lines` {#edit_lines}
Edit lines by number. Replaces lines from `start` to `end` (inclusive) with `new_string`. Use empty `new_string` to delete a range. Do not use with the batch tool.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `end` | integer | yes | Last line, inclusive |
| `new_string` | string | yes | Replacement text |
| `path` | string | yes | Absolute path to the file |
| `start` | integer | yes | First line (1-indexed) |
### `insert_lines` opt-in {#insert_lines}
Insert `new_string` after line `line`, or at the top with 0. Only include new lines, never lines already in the file. Do not use with the batch tool.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `line` | integer | yes | Line number to insert after (1-indexed). Use 0 to insert at the top. |
| `new_string` | string | yes | Text to insert |
| `path` | string | yes | Absolute path to the file |
### `glob` {#glob}
Find files by glob pattern.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `path` | string | no | cwd | Directory to search in |
| `pattern` | string | yes | | Glob pattern (e.g. **/*.rs, src/**/*.ts) |
### `grep` {#grep}
Search file contents using regex.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `context_after` | integer | no | | Context lines after match |
| `context_before` | integer | no | | Context lines before match |
| `include` | string | no | | File glob filter (e.g. *.c) |
| `limit` | integer | no | | Max match groups to return |
| `path` | string | no | cwd | Directory to search in |
| `pattern` | string | yes | | Regex pattern |
### `index` {#index}
Return a compact overview of a source file: imports, type definitions, function signatures, and structure with their line numbers surrounded by []. ~70-90% more efficient than reading the full file.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `path` | string | yes | Absolute path to the file |
### `view_image` {#view_image}
View an image file (png, jpeg, gif, webp) so you can actually see it; it is returned as vision input alongside the tool result. Use instead of `read` for images.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `path` | string | yes | Path to the image file |
## Execution & Control
### `batch` {#batch}
Executes multiple independent tool calls concurrently to reduce round-trips.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `tool_calls` | array | yes | Array of tool calls to execute in parallel |
### `code_execution` {#code_execution}
Execute Python in a sandbox where every tool is an async function.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `code` | string | yes | | Python code. Tools return strings, not objects, and you MUST await every call: `result = await read(path='/file', offset=1, limit=0)`. |
| `timeout` | integer | no | 30 | Script execution timeout in seconds |
### `question` {#question}
Use this tool when you need to ask the user questions during execution. This allows you to:
- Gather user preferences or requirements
- Clarify ambiguous instructions
- Get decisions on implementation choices as you work
- Offer choices to the user about what direction to take
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `questions` | array | yes | List of questions to ask the user |
## Agent & Knowledge
### `task` {#task}
Launch an autonomous subagent to perform tasks independently. Best combined with batch.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `description` | string | yes | Short (3-5 words) description of the task |
| `model_tier` | string | no | Model tier (optional, omit to use current model, capped at current tier):
- "strong" (e.g. Opus): Deep reasoning, complex architecture, subtle bugs, most critical sections. ~5x cost of medium.
- "medium" (e.g. Sonnet): Balanced. Refactors, features, multi-file changes.
- "weak" (e.g. Haiku): Fast/cheap. Search, summarize, boilerplate, simple edits. |
| `output_schema` | string | no | JSON Schema (object) the subagent's final result must match. When set, the result is returned as a validated JSON string. |
| `prompt` | string | yes | Detailed task prompt for the agent |
| `subagent_type` | string | no | Subagent type: "research" (read-only, default) or "general" (can modify files) |
| `thinking` | string | no | Thinking: off\|adaptive\|minimal\|low\|medium\|high\|xhigh\|max\|int budget. Omit to inherit parent; capped at parent. |
### `todo_write` {#todo_write}
Create or update a structured todo list to track tasks.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `todos` | array | yes | The updated todo list |
### `memory` {#memory}
Persistent, project-scoped scratchpad for learnings, patterns, decisions, and gotchas across sessions.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `command` | string | yes | - `list [tags]`: tag-grouped index, no bodies.
- `read path\|tags`: one body (path) or collated bodies (tags).
- `write path tags content`: create or overwrite a note.
- `delete path` |
| `content` | string | no | Body for write (frontmatter added automatically). |
| `path` | string | no | Relative path, e.g. 'architecture.md'. |
| `tags` | array | no | snake_case tags. Filter for list/read; assigned on write (defaults to filename stem). |
### `skill` {#skill}
Load a skill that provides instructions and workflows for specific tasks.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | yes | Name of the skill to load |
## Web
### `webfetch` {#webfetch}
Fetch a URL and return its contents.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `format` | string | no | | Output format: markdown (default), text, or html |
| `timeout` | integer | no | 30, max 120 | Timeout in seconds |
| `url` | string | yes | | URL to fetch (http:// or https://) |
### `websearch` {#websearch}
Search the web for real-time information using Exa AI.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `num_results` | integer | no | 8 | Number of results to return |
| `query` | string | yes | | Search query |
---
# Providers
Maki talks to LLM providers over their HTTP APIs. Models are split into three tiers: **weak** (cheap and fast), **medium** (balanced), and **strong** (highest capability, highest cost). There is also a **compaction** tier for choosing a dedicated model to summarize context when the conversation grows long.
Open the model picker with `/model` and press `!`, `@`, `#`, or `$` on any row to assign it to strong, medium, weak, or compaction. Press the same key again to remove the assignment. Your overrides are saved to `~/.local/state/maki/model-tiers` and apply across sessions.
## Auth Reloading
Maki re-reads auth from storage and environment variables each time a new agent spawns (`/new`, retry, session load). If you run `maki auth login` in another terminal or change an env var, the next session picks it up without a restart.
You can set multiple API keys in one env var (`ANTHROPIC_API_KEY=sk-1,sk-2,sk-3`) and they rotate automatically on rate-limit or auth errors.
## Base URL Overrides
Every provider honors a `_BASE_URL` env var (`anthropic` -> `ANTHROPIC_BASE_URL`, `llama-cpp` -> `LLAMA_CPP_BASE_URL`). Set it to the origin of a proxy or a compatible endpoint and Maki appends the API paths itself:
```sh
ANTHROPIC_BASE_URL=https://my-proxy.internal maki
```
It wins over `providers.toml` and built-in defaults. `ANTHROPIC_BASE_URL` and `OPENAI_BASE_URL` are the same names the official SDKs use, so an existing proxy setup carries over as is. Two exceptions: `OPENAI_BASE_URL` only redirects the platform API, never the ChatGPT Coding Plan backend; `XAI_BASE_URL` only redirects the public API-key endpoint, never the OAuth CLI proxy.
You can also set `base_url` for a built-in provider in `~/.config/maki/providers.toml`. It overrides the built-in default and loses to the env var above:
```toml
[openai]
base_url = "http://xxxx:1234/v1"
```
The built-in provider still owns the slug, so `protocol`, `api_key_env`, `discover_models` and `models` are ignored with a warning. Use a custom slug if you need those.
## Built-in Providers
### Anthropic
- **Env var**: `ANTHROPIC_API_KEY`
- **API**: `https://api.anthropic.com/v1/messages`
- **Features**: Prompt caching, thinking mode (adaptive/budgeted), advanced tool use
| Tier | Models | Pricing (in/out per 1M tokens) | Context |
|------|--------|-------------------------------|---------|
| Weak | **claude-haiku-4-5** (default) | $1.00 / $5.00 | 200K ctx / 64K out |
| Medium | claude-sonnet-4-5 | $3.00 / $15.00 | 200K ctx / 64K out |
| Medium | claude-sonnet-4-6 | $3.00 / $15.00 | 200K ctx / 64K out |
| Medium | **claude-sonnet-5** (default) | $2.00 / $10.00 | 200K ctx / 128K out |
| Medium | claude-sonnet-4 | $3.00 / $15.00 | 200K ctx / 64K out |
| Strong | claude-opus-4-5 | $5.00 / $25.00 | 200K ctx / 64K out |
| Strong | claude-opus-4-6 | $5.00 / $25.00 | 200K ctx / 128K out |
| Strong | claude-opus-4-7 | $5.00 / $25.00 | 200K ctx / 128K out |
| Strong | claude-opus-4-8 | $5.00 / $25.00 | 200K ctx / 128K out |
| Strong | **claude-opus-5** (default) | $5.00 / $25.00 | 200K ctx / 128K out |
| Strong | claude-fable-5 | $10.00 / $50.00 | 200K ctx / 128K out |
| Strong | claude-opus-4-0, claude-opus-4-1 | $15.00 / $75.00 | 200K ctx / 32K out |
Defaults: claude-haiku-4-5 (weak), claude-sonnet-5 (medium), claude-opus-5 (strong)
Add `-1m` to any Claude model, like `claude-sonnet-4-6-1m`, to use the 1M token context window.
#### Amazon Bedrock
If you already use Claude through AWS Bedrock, you can point Maki at it instead of the direct Anthropic API. Set `CLAUDE_CODE_USE_BEDROCK=1` and Maki will route all Anthropic requests through Bedrock. The same models, the same features, just a different door.
You will need `AWS_REGION` and one of the following for auth:
| Method | Env vars |
|--------|----------|
| IAM credentials | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (and optionally `AWS_SESSION_TOKEN`) |
| Credentials file | `AWS_PROFILE` (defaults to `default`), reads `~/.aws/credentials` |
| Bearer token | `AWS_BEARER_TOKEN_BEDROCK` |
| Gateway proxy | `CLAUDE_CODE_SKIP_BEDROCK_AUTH=1` + `ANTHROPIC_BEDROCK_BASE_URL` (skips signing, useful behind a proxy that handles auth) |
You can override the model with `ANTHROPIC_MODEL` and the endpoint with `ANTHROPIC_BEDROCK_BASE_URL`. These env var names match Claude Code, so if you were already using Bedrock there, the same setup works here.
### OpenAI
- **Env var**: `OPENAI_API_KEY` (also supports OAuth via `maki auth login openai`)
- **API**: `https://api.openai.com/v1`
| Tier | Models | Pricing (in/out per 1M tokens) | Context |
|------|--------|-------------------------------|---------|
| Weak | **gpt-5.6-luna** (default) | $1.00 / $6.00 | 372K ctx / 128K out |
| Weak | gpt-5.4-nano | $0.20 / $1.25 | 400K ctx / 128K out |
| Weak | gpt-5.4-mini | $0.75 / $4.50 | 400K ctx / 128K out |
| Weak | gpt-4.1-nano | $0.10 / $0.40 | 1047K ctx / 32K out |
| Medium | **gpt-5.6-terra** (default) | $2.50 / $15.00 | 372K ctx / 128K out |
| Medium | gpt-4.1-mini | $0.40 / $1.60 | 1047K ctx / 32K out |
| Medium | gpt-4.1 | $2.00 / $8.00 | 1047K ctx / 32K out |
| Medium | o4-mini | $1.10 / $4.40 | 200K ctx / 100K out |
| Medium | gpt-5.1-codex-mini | $0.25 / $2.00 | 400K ctx / 128K out |
| Strong | **gpt-5.6-sol** (default) | $5.00 / $30.00 | 372K ctx / 128K out |
| Strong | gpt-6-astra | $10.00 / $50.00 | 1050K ctx / 128K out |
| Strong | gpt-5.5 | $5.00 / $30.00 | 1050K ctx / 128K out |
| Strong | gpt-5.4 | $2.50 / $15.00 | 1050K ctx / 128K out |
| Strong | o3 | $2.00 / $8.00 | 200K ctx / 100K out |
| Strong | gpt-5.3-codex | $1.75 / $14.00 | 400K ctx / 128K out |
| Strong | gpt-5.2-codex | $1.75 / $14.00 | 400K ctx / 128K out |
| Strong | gpt-5.1-codex-max | $1.25 / $10.00 | 400K ctx / 128K out |
| Strong | gpt-5.1-codex | $1.25 / $10.00 | 400K ctx / 128K out |
Defaults: gpt-5.6-luna (weak), gpt-5.6-terra (medium), gpt-5.6-sol (strong)
`maki auth login openai` offers browser login (PKCE, callback on `localhost:1455`) and device code login. Browser is the desktop default; device code is recommended over SSH or in a container. Tokens refresh automatically.
With ChatGPT OAuth the model list comes from the Codex backend's own `/models` endpoint, so a model your plan gains shows up without a Maki update, with the context window and reasoning levels the backend declares for it. The table above is the offline fallback. The endpoint hides models newer than the Codex CLI version Maki reports, so a brand new release can lag until that version is bumped.
### Google
- **Env var**: `GEMINI_API_KEY`
- **API**: `https://generativelanguage.googleapis.com/v1beta`
- **Features**: Native Gemini API with thinking support
| Tier | Models | Pricing (in/out per 1M tokens) | Context |
|------|--------|-------------------------------|---------|
| Weak | **gemini-2.0-flash-lite** (default) | $0.07 / $0.30 | 1048K ctx / 65K out |
| Medium | **gemini-2.5-flash** (default) | $0.15 / $0.60 | 1048K ctx / 65K out |
| Strong | **gemini-2.5-pro** (default) | $1.25 / $5.00 | 1048K ctx / 65K out |
Defaults: gemini-2.5-pro (strong), gemini-2.5-flash (medium), gemini-2.0-flash-lite (weak)
### Copilot
- **Env var**: `GH_COPILOT_TOKEN` (or run `maki auth login copilot` to import a token from gh CLI, the Copilot client, or the system keyring)
- **API**: `https://api.githubcopilot.com (or GraphQL-discovered Copilot API endpoint)`
- **Features**: Native Copilot Chat HTTP API with model endpoint discovery
| Tier | Models | Pricing (in/out per 1M tokens) | Context |
|------|--------|-------------------------------|---------|
| Weak | gpt-5-mini | $0.25 / $2.00 | 200K ctx / 100K out |
| Weak | gpt-5.4-mini | $0.75 / $4.50 | 200K ctx / 100K out |
| Weak | gpt-5.4-nano | $0.20 / $1.25 | 200K ctx / 100K out |
| Weak | claude-haiku-4.5 | $1.00 / $5.00 | 200K ctx / 64K out |
| Weak | gemini-3.5-flash | $1.50 / $9.00 | 200K ctx / 65K out |
| Weak | mai-code-1-flash-picker | $0.75 / $4.50 | 200K ctx / 100K out |
| Weak | **gpt-5.6-luna** (default) | $0.20 / $1.20 | 200K ctx / 100K out |
| Medium | gemini-3.6-flash | $0.75 / $3.75 | 200K ctx / 65K out |
| Medium | gemini-3.7-flash | $0.75 / $3.75 | 200K ctx / 65K out |
| Medium | claude-sonnet-4.5, claude-sonnet-4.6 | $3.00 / $15.00 | 200K ctx / 64K out |
| Medium | claude-sonnet-5 | $2.00 / $10.00 | 200K ctx / 100K out |
| Medium | kimi-k2.7-code | $0.95 / $4.00 | 200K ctx / 100K out |
| Medium | gemini-3.1-pro-preview | $2.00 / $12.00 | 200K ctx / 65K out |
| Medium | **gpt-5.6-terra** (default) | $2.00 / $12.00 | 200K ctx / 100K out |
| Medium | grok-4.5 | $2.00 / $6.00 | 200K ctx / 100K out |
| Medium | grok-4.6 | $2.00 / $6.00 | 200K ctx / 100K out |
| Strong | gpt-5.5 | $5.00 / $30.00 | 200K ctx / 100K out |
| Strong | kimi-k3 | $3.00 / $15.00 | 200K ctx / 100K out |
| Strong | gpt-5.4 | $2.50 / $15.00 | 200K ctx / 100K out |
| Strong | gpt-5.6-sol | $5.00 / $30.00 | 200K ctx / 100K out |
| Strong | gpt-5.3-codex | $1.75 / $14.00 | 200K ctx / 100K out |
| Strong | **claude-opus-5, claude-opus-4.8, claude-opus-4.7, claude-opus-4.6, claude-opus-4.5** (default) | $5.00 / $25.00 | 200K ctx / 64K out |
| Strong | claude-opus-4.8-fast, claude-fable-5 | $10.00 / $50.00 | 200K ctx / 100K out |
Defaults: gpt-5.6-luna (weak), gpt-5.6-terra (medium), claude-opus-5 (strong)
### Ollama
- **Env var**: `OLLAMA_HOST` for local/remote (e.g. `http://localhost:11434`), `OLLAMA_API_KEY` for auth
- **API**: `http://localhost:11434/v1`
- **Features**: Local or remote inference via OLLAMA_HOST, cloud fallback via OLLAMA_API_KEY
This provider talks the OpenAI-compatible `/v1` API, so it also works with llama.cpp's server, LocalAI, or anything else that speaks the same protocol. Just point `OLLAMA_HOST` to the right address (e.g. `http://localhost:8080` for llama.cpp).
### LlamaCpp
- **Env var**: `LLAMA_CPP_API_KEY`
- **API**: `http://localhost:8080/v1`
- **Features**: Local or remote inference via LLAMA_CPP_HOST, set optional key via LLAMA_CPP_API_KEY
Connects to any OpenAI-compatible `/v1` endpoint. Point `LLAMA_CPP_HOST` to your server address (defaults to `http://localhost:8080`).
### Mistral
- **Env var**: `MISTRAL_API_KEY`
- **API**: `https://api.mistral.ai/v1`
| Tier | Models | Pricing (in/out per 1M tokens) | Context |
|------|--------|-------------------------------|---------|
| Weak | **ministral-14b-latest, ministral-14b-2512** (default) | $0.20 / $0.20 | 262K ctx |
| Medium | **mistral-small-latest, mistral-small-2603** (default) | $0.15 / $0.60 | 262K ctx |
| Strong | **mistral-medium-latest, mistral-medium-3.5, mistral-medium-3-5, mistral-medium-2604** (default) | $1.50 / $7.50 | 262K ctx |
| Strong | glm-5-2, zai-glm-5-2 | $1.40 / $4.40 | 1000K ctx |
Defaults: mistral-medium-latest (strong), mistral-small-latest (medium), ministral-14b-latest (weak)
### Z.AI
- **Env var**: `ZHIPU_API_KEY` (shared across both endpoints)
- **API endpoints**:
- `https://api.z.ai/api/paas/v4`
- `https://api.z.ai/api/coding/paas/v4`
| Tier | Models | Pricing (in/out per 1M tokens) | Context |
|------|--------|-------------------------------|---------|
| Weak | glm-5.3-flash | $0.15 / $0.50 | 1000K ctx / 131K out |
| Weak | **glm-4.7-flash** (default) | $0.00 / $0.00 | 200K ctx / 131K out |
| Weak | glm-4.5-flash | $0.00 / $0.00 | 131K ctx / 98K out |
| Weak | glm-4.5-air | $0.20 / $1.10 | 131K ctx / 98K out |
| Medium | **glm-4.7, glm-4.6** (default) | $0.60 / $2.20 | 200K ctx / 131K out |
| Medium | glm-4.5 | $0.60 / $2.20 | 131K ctx / 98K out |
| Strong | **glm-5-code** (default) | $1.20 / $5.00 | 200K ctx / 131K out |
| Strong | glm-5.3 | $1.40 / $4.40 | 1000K ctx / 131K out |
| Strong | glm-5.2 | $1.40 / $4.40 | 1000K ctx / 131K out |
| Strong | glm-5.1 | $1.40 / $4.40 | 200K ctx / 131K out |
| Strong | glm-5 | $1.00 / $3.20 | 200K ctx / 131K out |
Defaults: glm-5-code (strong), glm-4.7-flash (weak), glm-4.7 (medium)
### DeepSeek
- **Env var**: `DEEPSEEK_API_KEY`
- **API**: `https://api.deepseek.com`
- **Features**: Thinking mode toggle (on/off), open-weight models
- **Peak pricing**: the prices below are off-peak; each turn is billed as it happens, at 2x during 01:00-04:00, 06:00-10:00 UTC, Mon-Fri
| Tier | Models | Pricing (in/out per 1M tokens) | Context |
|------|--------|-------------------------------|---------|
| Medium | **deepseek-flash, deepseek-v4-flash** (default) | $0.15 / $0.60 | 1000K ctx / 384K out |
| Strong | **deepseek-v4-pro** (default) | $0.66 / $1.98 | 1000K ctx / 384K out |
Defaults: deepseek-flash (medium), deepseek-v4-pro (strong)
### OpenRouter
- **Env var**: `OPENROUTER_API_KEY`
- **API**: `https://openrouter.ai/api/v1`
- **Features**: 300+ models from all providers, prompt caching, provider routing
OpenRouter aggregates models from many providers behind a single API key. Browse available models at [openrouter.ai/models](https://openrouter.ai/models). Use any model ID directly (e.g. `openrouter/anthropic/claude-sonnet-4`).
### Requesty
- **Env var**: `REQUESTY_API_KEY`
- **API**: `https://router.requesty.ai/v1`
- **Features**: 700+ models behind one key, curated managed routing policies, EU region via `REQUESTY_BASE_URL`
Requesty routes 700+ models from many providers behind a single API key. Models are listed live from the API: curated managed policies first (short ids such as `requesty/claude-sonnet-4-5` or `requesty/gpt-5.4-mini`, `@eu` variants route only through EU providers), then the full `/` catalog (e.g. `requesty/openai/gpt-4o-mini`). Get a key at [app.requesty.ai/api-keys](https://app.requesty.ai/api-keys). Set `REQUESTY_BASE_URL=https://router.eu.requesty.ai/v1` to keep all traffic in the EU.
### Synthetic
- **Env var**: `SYNTHETIC_API_KEY`
- **API**: `https://api.synthetic.new/openai/v1`
- **Features**: Reasoning effort support (low/medium/high), open-weight models
| Tier | Models | Pricing (in/out per 1M tokens) | Context |
|------|--------|-------------------------------|---------|
| Weak | **hf:zai-org/GLM-4.7-Flash** (default) | $0.10 / $0.50 | 200K ctx / 131K out |
| Medium | **hf:deepseek-ai/DeepSeek-V3.2** (default) | $0.56 / $1.68 | 200K ctx / 131K out |
| Strong | **hf:moonshotai/Kimi-K2.5** (default) | $0.45 / $3.40 | 200K ctx / 131K out |
Defaults: hf:moonshotai/Kimi-K2.5 (strong), hf:deepseek-ai/DeepSeek-V3.2 (medium), hf:zai-org/GLM-4.7-Flash (weak)
### Regolo
- **Env var**: `REGOLO_API_KEY`
- **API**: `https://api.regolo.ai/v1`
- **Features**: EU-hosted open-weight models with tool calling. The catalogue and prices are listed live from the API
| Tier | Models | Pricing (in/out per 1M tokens) | Context |
|------|--------|-------------------------------|---------|
| Weak | **qwen3.5-9b** (default) | $0.07 / $0.35 | 80K ctx / 80K out |
| Medium | **qwen3-coder-next** (default) | $0.50 / $2.00 | 120K ctx / 120K out |
| Strong | **qwen3.5-122b** (default) | $1.00 / $4.20 | 120K ctx / 120K out |
Defaults: qwen3.5-122b (strong), qwen3-coder-next (medium), qwen3.5-9b (weak)
### TensorX
- **Env var**: `TENSORX_API_KEY`
- **API**: `https://api.tensorx.ai/v1`
- **Features**: Open-weight models, zero data retention, prompt caching
No hardcoded model catalog. Use any model ID supported by this provider.
### Opencode Zen
- **Env var**: `OPENCODE_API_KEY`
- **API**: `https://opencode.ai/zen/v1`
- **Features**: Dynamically discovered models via [models.dev](https://models.dev/) + all the models provided by Opencode Zen API
No hardcoded model catalog. Use any model ID supported by this provider.
By default Maki hides free models from the Opencode catalog. To list free models (they use a public fallback, no API key needed), add this to `~/.config/maki/providers.toml`:
```toml
[opencode]
enable_free_models = true
```
The default is `false`.
### xAI
- **Env var**: `XAI_API_KEY` (also supports OAuth via `maki auth login xai`)
- **API endpoints**:
- `https://api.x.ai/v1`
- `https://cli-chat-proxy.grok.com/v1`
- **Features**: OAuth login, account-specific model catalog, Grok reasoning (low/medium/high/xhigh)
| Tier | Models | Pricing (in/out per 1M tokens) | Context |
|------|--------|-------------------------------|---------|
| Medium | **grok-4.3** (default) | $1.25 / $2.50 | 1000K ctx / 131K out |
| Strong | **grok-4.6** (default) | $2.00 / $6.00 | 500K ctx / 131K out |
| Strong | grok-4.5 | $2.00 / $6.00 | 500K ctx / 131K out |
Defaults: grok-4.6 (strong), grok-4.3 (medium)
OAuth uses the same first-party xAI client as the official Grok CLI (`maki auth login xai`). Browser login (PKCE) is the desktop default; device code is recommended over SSH or in a container. Tokens refresh automatically. After login, Maki fetches your account catalog from `GET /v1/models-v2` on the Grok CLI proxy and caches it for 15 minutes. `XAI_BASE_URL` only redirects the public API-key endpoint, never the OAuth proxy.
If `~/.grok/auth.json` already exists, login offers to reuse it without writing that file.
### Aperture
- **Env var**: `APERTURE_HOST` (e.g. `https://your-host.tailnet.ts.net`)
- **API**: `Aperture gateway (set APERTURE_HOST)`
- **Features**: Tailscale Aperture LLM gateway; set APERTURE_HOST or configure in providers.toml
Aperture discovers models from your gateway. Set `APERTURE_HOST` to your Tailscale Aperture endpoint (e.g. `https://your-host.tailnet.ts.net`). No API key needed, Tailscale handles auth.
### Opencode Go
- **Env var**: `OPENCODE_API_KEY`
- **API**: `https://opencode.ai/zen/go/v1`
- **Features**: Dynamically discovered models via [models.dev](https://models.dev/) + all the models provided by Opencode Go API
No hardcoded model catalog. Use any model ID supported by this provider. An API key is required.
## Model Identifiers
Models are referenced as `provider/model_id`:
```
anthropic/claude-sonnet-4-6
openai/gpt-4.1
xai/grok-4.6
zai/glm-4.7
```
If the model name is unique across providers, the prefix can be omitted.
### Models newer than your Maki version
The tables above list the models Maki curates. Any other id a provider accepts works too: type it into `/model` or pass it to `--model`. The picker also lists what the provider's own model endpoint reports, so same-day releases are selectable there.
For an id no table covers, rates, context window, vision and thinking support come from [models.dev](https://models.dev/), refreshed daily (`maki models --refresh` forces it). Maki reads each field on its own, so a row that lists a price but no context window still leaves the window to the sources below.
Sources rank by how sure they are to describe the exact model you asked for:
1. What the provider's own model endpoint reported this session.
2. A curated row for that id, including its dated snapshots. `claude-sonnet-4-5-20250929` reads the `claude-sonnet-4-5` row.
3. models.dev.
4. A curated row for a close relative, reached by shared prefix. `glm-5.4` falls back to `glm-5` here, and takes its family and tier from it either way.
5. The provider's defaults, with no cost estimate.
A curated row is checked against the provider's own pricing page, so it wins for the id it names. For a relative it loses to models.dev, because a rate nobody checked against the id you typed is only a guess.
New models start at the **medium** tier until you assign one in the picker.
## providers.toml
`providers.toml` lives in the config directory (`~/.config/maki/providers.toml` on Linux/macOS, `%APPDATA%\maki\providers.toml` on Windows). It is the file for provider overrides and custom HTTP providers. Two jobs:
1. Tweak a built-in (pick a plan, change its base URL, set `enable_free_models` for Opencode).
2. Declare a custom provider that speaks OpenAI, Anthropic, or Google wire format.
```toml
# Point a built-in at a proxy. Env vars still win over this file.
[anthropic]
base_url = "https://my-proxy.internal"
# Full custom provider. Slug becomes the `provider/` prefix in model specs.
[my-proxy]
display_name = "My Proxy"
protocol = "openai" # openai | openai-responses | anthropic | google
base_url = "https://llm.example.com/v1"
api_key_env = "MY_PROXY_API_KEY"
default_model = "my-proxy/fast-v1"
discover_models = true # also list models via the provider's /models endpoint
[[my-proxy.models]]
id = "fast-v1"
tier = "weak"
context_window = 128000
max_output_tokens = 16384
pricing_input = 0.5
pricing_output = 1.5
[[my-proxy.models]]
id = "smart-v1"
tier = "strong"
context_window = 200000
max_output_tokens = 32000
supports_thinking = true
supports_vision = false
```
### Provider fields
| Field | Type | Notes |
|-------|------|-------|
| `display_name` | string | Shown in pickers and auth status |
| `protocol` | string | `openai`, `openai-responses`, `anthropic`, or `google`. Required for custom slugs |
| `base_url` | string | Origin of the API. Maki appends the protocol paths |
| `plan` | string | Built-in plan key (see Plans below). Sets base URL and default model |
| `api_key_env` | string | Env var that holds the key. Defaults to `_API_KEY` |
| `api_key` | string | Inline key (prefer the env var or `maki auth login`) |
| `headers` | table | Extra HTTP headers sent on every request to this provider. Values expand `${VAR}` from the environment; an unset or empty variable fails the provider instead of sending a half-filled header. A same-name header (case-insensitive) replaces the built-in auth header and survives key rotation |
| `default_model` | string | Used after login when no model is saved yet |
| `discover_models` | bool | When true, also probe the provider's model list endpoint (default false) |
| `enable_free_models` | bool | Opencode only. Show free catalog models (default false) |
| `models` | array | Declared models for custom providers (see below) |
| `overrides` | table | Aperture only. Per-upstream model overrides (see below) |
### Model fields
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `id` | string | required | Model id. Spec becomes `{slug}/{id}` |
| `tier` | string | `medium` | `weak`, `medium`, `strong`, or `compaction` |
| `context_window` | u32 | protocol default | Tokens of context |
| `max_output_tokens` | u32 | protocol default | Max completion tokens |
| `supports_tool_examples` | bool | protocol default | |
| `supports_thinking` | bool | protocol default | |
| `requires_thinking` | bool | false | For APIs that reject requests with thinking disabled. Implies `supports_thinking` and raises thinking to minimal effort when off (including compaction) |
| `supports_vision` | bool | protocol default | When false, image input and `view_image` are off |
| `pricing_input` / `pricing_output` | f64 | 0 | USD per 1M tokens |
| `pricing_cache_write` / `pricing_cache_read` | f64 | 0 | USD per 1M tokens |
| `pricing_fast_input` / `pricing_fast_output` | f64 | unset | Fast-mode pricing when the provider supports it |
Custom slugs must not reuse a built-in provider name. A bad TOML parse exits with code 2 at startup so a typo cannot silently empty the registry.
You can also create a custom provider interactively with `maki auth login` and choosing the custom option. That writes a starter entry to this file.
### Aperture overrides
Aperture proxies upstream providers, exposing each model as `aperture//`. Overrides keyed by upstream provider id live under `[aperture.overrides]`:
```toml
[aperture.overrides.llmserver]
base = "llama-cpp"
context_window = 131072
max_output_tokens = 16384
[aperture.overrides.llmserver.models."qwen-3.6"]
context_window = 262144
supports_vision = true
```
Provider-level fields apply to every model from that upstream; per-model entries under `models` win field by field. Fields: `context_window`, `max_output_tokens`, `supports_thinking`, `supports_vision`, `base` (remaps an opaque vendor to a native provider; e.g. `llama-cpp`, `google`, `anthropic`), and `path_prefix`. Model ids containing dots must be quoted (`"qwen3.6"`) since TOML treats a bare dotted key as a nested table.
Maki sends `/v1` (or `/v1beta` for Gemini routes, nothing for Anthropic and Z.AI), and Aperture appends that path to the upstream's base url. If an upstream base url already carries its own path, set `path_prefix = ""` for it to avoid a doubled path. Z.AI defaults to no prefix since its API path has no `/v1` segment; point the upstream base url at the full API root (e.g. `https://api.z.ai/api/paas/v4`).
### Plans
Some built-ins ship multiple plans (different base URLs or default models). `maki auth login ` asks which plan to use when more than one exists. You can also set it in TOML:
```toml
[mistral]
plan = "coding"
[zai]
plan = "coding"
```
Current plans:
| Provider | Plan | What it does |
|----------|------|--------------|
| Mistral | `standard` | Standard at `https://api.mistral.ai/v1`, default `mistral/mistral-medium-latest` |
| Mistral | `coding` | Vibe / Coding at `https://api.mistral.ai/v1`, default `mistral/mistral-vibe-cli-latest` |
| Z.AI | `standard` | Pay-as-you-go at `https://api.z.ai/api/paas/v4`, default `zai/glm-5.1` |
| Z.AI | `coding` | Coding plan at `https://api.z.ai/api/coding/paas/v4`, default `zai/glm-5-code` |
Env `_BASE_URL` still wins over both the plan and a `base_url` in this file.
## Dynamic Providers
To add a custom provider or proxy, drop an executable script into the config `providers/` directory (`~/.config/maki/providers/` on Linux/macOS, `%APPDATA%\maki\providers\` on Windows). The script must handle these subcommands:
| Subcommand | Timeout | What it does |
|------------|---------|--------|
| `info` | 5s | Return JSON with `display_name`, `base` provider, `has_auth` |
| `models` | 5s | Return JSON array of model entries (optional) |
| `resolve` | 30s | Return auth JSON (`base_url`, `headers`) |
| `login` | interactive | OAuth or credential flow |
| `logout` | interactive | Clear credentials |
| `refresh` | 30s | Refresh auth tokens |
`resolve` is called each time a new agent spawns, so scripts should read tokens from disk instead of caching them in memory. That way auth changes from other processes get picked up.
The `base` field specifies which built-in provider to inherit the model catalog from. Valid values: `anthropic`, `openai`, `google`, `copilot`, `ollama`, `llama-cpp`, `mistral`, `zai`, `deepseek`, `openrouter`, `requesty`, `synthetic`, `regolo`, `tensorx`, `opencode`, `xai`, `aperture`.
If your provider serves models not in the base catalog, add a `models` subcommand returning:
```json
[{"id": "my-model-v2", "tier": "strong", "context_window": 200000, "max_output_tokens": 16384}]
```
Only `id` is required. Optional fields: `tier` (default `medium`), `context_window` (128K), `max_output_tokens` (16K), `pricing` (`{input, output, cache_write, cache_read}`, all per 1M tokens), `supports_tool_examples` (defaults to the base provider's setting), `supports_thinking` (defaults to the base provider's setting), `requires_thinking` (default false; for APIs that reject requests with thinking off, raises it to minimal effort and implies `supports_thinking`), `supports_vision` (defaults to the base provider's setting; when false, image input and the `view_image` tool are disabled). The first model listed per tier is used for sub-agents. Without this subcommand, the base provider's models are used.
A `llama-cpp` model can replace Maki's token-budget mapping with its native thinking fields. Each thinking mode maps to a JSON fragment merged into the request body:
```json
[{
"id": "reasoning-model",
"supports_thinking": true,
"thinking_fields": {
"off": {"reasoning_effort": "none"},
"adaptive": {"reasoning_effort": "medium"},
"low": {"reasoning_effort": "low"},
"medium": {"reasoning_effort": "medium"},
"xhigh": {"reasoning_effort": "xhigh"}
}
}]
```
`off` is used when thinking is off, `adaptive` when thinking is on without a chosen level. Any other key is an effort level, one of `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. The levels you declare are the ones the model accepts: whatever you ask for snaps into them, downwards first, so a level the model never advertised is never sent. Every part is optional.
Fragments are merged into the body, so nesting works too. A template toggle is just a fragment:
```json
"thinking_fields": {
"off": {"chat_template_kwargs": {"enable_thinking": false}},
"adaptive": {"chat_template_kwargs": {"enable_thinking": true}}
}
```
Named modes send only these fields, no token budget. An explicit `/thinking ` snaps into the levels you declared; a model that declares none gets the `adaptive` fragment plus `thinking_budget_tokens`. Any mode you left undeclared falls back to the usual `thinking_budget_tokens` mapping, so no request ever ends up saying nothing. Models without `thinking_fields` keep the existing llama.cpp behavior.
Dynamic provider models are namespaced as `{slug}/{model_id}` (e.g. `myproxy/claude-sonnet-4-6`).
### Script Name Rules
- Must start with a letter or digit
- Only letters, digits, underscores, and hyphens after that
- Can't reuse a built-in provider's slug
- Must be executable
---
# Permissions
Maki uses a permission system to decide what each tool is allowed to do and when to ask you first.
Whether a project's `.maki` configuration loads at all is a separate question,
answered once per folder. See [folder trust](/docs/folder-trust/).
## Rule Layers
Rules come from four layers, combined for resolution:
1. **Session rules**, set during the current session (in-memory only)
2. **Config rules**, loaded from TOML permission files
3. **Builtin rules**, the hardcoded defaults
4. **Plugin rules**, declared by plugins via [`maki.api.register_permission_rule`](/docs/lua-api/#maki-api-register_permission_rule)
Any matching deny blocks the tool. No exceptions, so a config deny always beats a plugin allow.
A [`tool..input` hook](/docs/hooks/) runs before any of this. Rules are
resolved against the call as the hook left it, so what the prompt shows you is
what runs.
## Check Flow
For every tool call, each scope resolves like this:
```
tool call
│
deny rule matches? ── yes ──► blocked. no exceptions
│ no
allow rule matches? ── yes ──► runs
│ no
YOLO active? ── yes ──► runs
│ no
plan file write? ── yes ──► runs
│ no
▼
default: prompt / allow / deny
```
Deny rules are checked across all layers before anything else, so a deny cannot be bypassed by YOLO or the plan-file auto-allow. In plan mode, writes to any path other than the plan file are rejected before this flow; this applies to the file-write tools only. All other tools, including MCP tools, follow the check flow below as usual. `default` resolves per-tool first, then global; the built-in default is `"prompt"`.
## Builtin Defaults
File-write tools are pre-allowed inside the project working directory (cwd at session start, canonicalized). Paths outside that tree still need a prompt or an explicit allow rule:
| Tool | Scope | Notes |
|------|-------|-------|
| `write` | `/**` | Outside cwd requires permission |
| `edit` | `/**` | Outside cwd requires permission |
| `multiedit` | `/**` | Outside cwd requires permission |
| `edit_lines` | `/**` | Outside cwd requires permission |
| `insert_lines` | `/**` | Same, when the opt-in tool is enabled |
| `task` | `*` | Subagent spawning always allowed |
The memory plugin uses a plugin rule to pre-allow the file-write tools inside its notes directory (under maki's state dir), so the agent can edit memory notes directly without a prompt.
These tools have no builtin allow rule, so they prompt (or follow your `default`) every time unless you add rules:
- `bash` - Shell commands (scopes come from tree-sitter parsing)
- `websearch` - Web search queries
- `webfetch` - URL fetching
Tools that never declare permission scopes (for example `read`, `glob`, `grep`, `index`, `memory`, `skill`, `todo_write`) **skip** the permission manager entirely. They always run. If you need to block one of them, turn the plugin off in `init.lua` (`plugins.read = { enabled = false }`) rather than using `permissions.toml`.
Container tools like `batch` and `code_execution` prompt for each inner tool individually.
## TOML Configuration
There are two permission files:
- **Global**: `~/.config/maki/permissions.toml`
- **Project**: `.maki/permissions.toml` in the active Git checkout, or in the
working directory outside Git (takes precedence over global)
The project file's `deny` scopes always apply. The rest of it waits on
[folder trust](/docs/folder-trust/).
```toml
default = "deny"
[bash]
allow = [
"cargo *",
"git *",
]
deny = [
"rm -rf *",
"sudo *",
]
[read]
default = "allow"
[mcp.deepwiki]
allow = ["search", "fetch"]
[mcp.github]
deny = ["admin_delete"]
```
Each tool gets its own section with `allow` and `deny` arrays. Values are glob-like scope patterns.
> **Note:** In MCP server sections (`[mcp.*]`), the boolean forms `allow = true` and `deny = true` are deprecated and ignored. Use `default = "allow"` or `default = "deny"` instead. For native tool sections (e.g. `[bash]`), `allow = true` still works.
### The `default` key
Controls what happens when no allow or deny rule matches. Can be `"prompt"` (built-in default), `"deny"`, or `"allow"`. Set it globally or per-tool:
```toml
default = "deny"
[bash]
default = "prompt"
allow = ["cargo *"]
```
Here everything is denied by default, except `bash` which still prompts, and `cargo *` commands which are allowed.
Project files **cannot** set `default = "allow"` (top-level, per-tool, or MCP).
That value is ignored so a repository cannot grant itself full access. Project
**allow lists** work once the folder is [trusted](/docs/folder-trust/). Put
`default = "allow"` only in the global file.
## Scope Patterns
| Pattern | Matches |
|---------|--------|
| `*` or `**` | Any value (full wildcard) |
| `prefix*` | Values starting with prefix |
| `cmd *` | Bare `cmd` or `cmd` plus args (`pwd *` matches `pwd` and `pwd -L`, not `pwdx`) |
| `dir/**` | `dir` itself or anything under it (path-aware on Windows and Unix) |
| `exact` | Exact match only |
## MCP Tool Permissions
MCP tools use natural TOML nesting. Server names are table keys under `[mcp]`, tool names are array values:
```toml
# Global permissions.toml (default = "allow" is ignored in project files)
[mcp.deepwiki]
allow = ["search", "fetch"]
[mcp.github]
deny = ["admin_delete"]
[mcp.lean-lsp]
default = "allow" # allow all tools on this server (global only)
```
Tool names must match `^[a-zA-Z0-9_-]{1,64}$` (no dots, max 64 chars). Server names cannot contain dots.
## Permission Prompts
When a gated tool needs permission, Maki asks you.
| Key | Action |
|-----|--------|
| `y` | Allow once (immediate) |
| `s` | Allow for this session (confirm with `Enter` or `y`; any other key cancels) |
| `a` | Always allow for this project (confirm; saved to `.maki/permissions.toml`) |
| `A` | Always allow globally (confirm; saved to `~/.config/maki/permissions.toml`) |
| `n` | Open deny guidance editor (type optional guidance, then `Enter` to deny once; `Esc` cancels) |
| `d` | Deny always for this project (confirm; saved to `.maki/permissions.toml`) |
| `D` | Deny always globally (confirm) |
Session and always-allow / always-deny choices need a second key (`Enter` or `y`) so a fat-finger does not rewrite your rules. Deny-once with `n` lets you type a short reason the agent will see.
The keys are the same in a folder you have not
[trusted](/docs/folder-trust/), where `a` and `d` last for the session instead
of reaching `.maki/permissions.toml`.
ACP clients offer the four options the protocol defines. "Allow always" lasts
for the session, and "Reject always" is a project answer that follows folder
trust like the TUI, reading "Reject for this session" in an untrusted folder.
### Scope Generalization
When you pick "always allow" (or always deny for MCP), the saved scope is generalized so it stays useful beyond that one call:
- **bash**: `cargo test --all` becomes `cargo *`
- **write / edit / multiedit / edit_lines / insert_lines**: `/path/to/file.rs` becomes `/path/to/**`
- **MCP tools**: always `*` (per-tool, so allowing `deepwiki.search` will not cover `deepwiki.fetch`)
- **webfetch / websearch** (and anything else gated): the exact URL or query string is stored as-is
For MCP tools, both allow and deny decisions generalize to `*` (the entire tool). MCP inputs are opaque JSON with no meaningful scope pattern. Denying a single MCP invocation denies that tool until you revoke the rule.
## YOLO Mode
To skip prompts on gated tools, toggle YOLO with `/yolo`, or run with `--yolo`. Explicit deny rules still apply. The status bar shows `[yolo]` while it is on, and `/yolo` is stored with the session, so a resume comes back the same way. `--yolo` only sets the starting value for sessions you never toggled. Tools that never declare permission scopes are unaffected (they never prompted).
To start in YOLO mode every time:
```lua
-- ~/.config/maki/init.lua
maki.setup({
always_yolo = true,
})
```
## Bash Command Parsing
Bash commands get parsed with tree-sitter to extract individual commands. Something like `cd /tmp && cargo test` is checked as two separate commands.
Some constructs are too complex to analyze statically, so they always trigger a prompt:
- Command substitution: `$(...)`, backticks
- Process substitution: `<(...)`, `>(...)`
- Subshells: `(...)`
- Arithmetic expansion: `$((...))`
Brace groups `{ ... }` and control flow (`if`, `for`, …) are segmented when possible; they do not by themselves force a prompt the way substitutions do.
## Plugin Permissions
Lua plugins have a separate, unrelated gate. A `plugin.toml` manifest next to the Lua file controls which gated `maki.*` APIs it may call. No manifest means every gated call is denied, including for your own `init.lua`. The [Lua API reference](/docs/lua-api/#plugin-permissions) documents the manifest and lists every permission.
It runs after [folder trust](/docs/folder-trust/) has let the Lua file load, and
limits which APIs the file reaches rather than sandboxing the file.
## Network Addresses
`webfetch`, `websearch` and every plugin that calls `maki.net` go through one guard. A request to a private, loopback or link-local address is refused, and so is a redirect that lands on one. The model picks these URLs, so a page it reads could otherwise talk it into fetching `http://169.254.169.254/` or an admin panel on your LAN.
To reach a service on your own machine or network, list it in [`net.allowed_private_hosts`](/docs/configuration/#net). An allowed host also keeps plain `http://` instead of being upgraded to `https://`, since a service on your LAN rarely has a certificate.
## Session Persistence
When you save a session, its permission rules are saved too. Loading the session restores them.
---
# Folder Trust
A project `.maki` directory can run code on your machine before you type
anything. Maki loads none of it until you trust the folder.
| Gated file | What it can do |
|------------|----------------|
| `.maki/.env` | sets environment variables, including secrets, for Maki and every process it starts |
| `.maki/permissions.toml` | decides which tools run without asking |
| `.maki/init.lua` | runs Lua inside Maki's own process at startup |
| `.maki/mcp.toml` | starts MCP servers as child processes |
The first interactive start in an untrusted project draws a card before the
main UI opens, listing the gated files it found. It takes three answers:
| Answer | Effect |
|--------|--------|
| Trust | Project config loads this run and every later one. |
| Not now | Restricted this run, asked again next start. |
| Never | Restricted, and not asked again. |
"Not now" is preselected, so Enter and Escape are both safe. `t` or `y` answers
Trust, `n` answers Not now, and "Never" needs the arrow keys and Enter. Ctrl-C
exits Maki. A project that ships no gated file is never asked about.
One answer covers one project root: the active Git checkout, or the working
directory outside Git. Linked worktrees answer for themselves. Starting Maki in
your home directory loads no project configuration, because `~/.maki` there is
your global configuration.
## What Trust Does Not Cover
Trust gates code. Text that a project puts into the prompt loads at any trust
level:
- `AGENTS.md` and the other instruction files
- Commands under `.maki/commands` and `.claude/commands`
- Skills under `.maki/skills`, `.claude/skills`, `.opencode/skills` and
`.agents/skills`
A repository can still steer the agent through what the model reads, so trust
is not a sandbox. What limits the agent on each tool call is
[permissions](/docs/permissions/), at every trust level.
The `deny` scopes in `.maki/permissions.toml` apply without trust. Its `allow`
scopes and any `default` it sets are dropped, so a repository can only narrow
what the agent may do inside it.
## In an Untrusted Folder
Maki writes nothing into a folder you declined, and the status bar carries a
`[restricted]` indicator for the whole session. A folder with no `.maki` at all
shows no indicator.
The project answers in a [permission prompt](/docs/permissions/#permission-prompts)
still work and last until the session ends, labelled `Project (this session)`.
For an answer that outlives the session, use `A` or `D` to save it in your own
`~/.config/maki/permissions.toml`, or trust the folder.
## Managing Trust
```bash
maki trust add [PATH] # asks before recording
maki trust add [PATH] --yes # records a yes
maki trust remove [PATH] # clears a yes or a no
maki trust list # shows both kinds of decision
```
`PATH` defaults to the current directory. None of these commands start the Lua
host, so they are safe to run in a folder you have not read yet. Decisions are
stored outside the project and follow the checkout path.
Inside the TUI, `/trust` trusts the current folder and reloads plugins and
configuration. Typing it is the consent, so there is no second question. It
covers the gated files the folder had when the session started, so a kind the
project adds while Maki runs is asked about on the next start.
## Trust Policy
Answer in advance for paths you already trust:
```lua
maki.setup({
trust = {
paths = { "~/src/me/*", "/workspace" },
prompt = false,
},
})
```
Maki reads `trust` from the global `~/.config/maki/init.lua` only. A project
`.maki/init.lua` that sets it has the table stripped and gets a warning, since a
project shipping one would be granting itself trust.
Patterns are matched against the project root. `*` stays inside one path
segment, `**` crosses segments, and `~` expands to your home directory.
`paths = { "**" }` trusts every folder. A match is recorded like any other yes,
so `maki trust list` shows it and `maki trust remove` clears it.
The policy answers only a folder that has no answer yet, so a recorded `Never`
stays a `Never` however the globs are written. Clear it with
`maki trust remove PATH`, or `/trust` inside the TUI.
`prompt = false` drops the card and leaves the folder restricted unless a
`paths` entry matches.
The policy applies to the TUI, `-p`, the SDK and ACP. The utility subcommands
(`maki index`, `maki models`, `maki prompt`, `maki mcp auth`) skip it, since a
grant there would record a decision you never saw.
## Containers and CI
Headless runs, the SDK, ACP, and utility subcommands never ask. An untrusted
folder is skipped, the skipped path is reported on standard error, and the run
continues on global configuration.
Pass `--trust` where the container is already the boundary you rely on:
```bash
maki --trust -p "run the test suite"
```
The flag loads the project configuration for that run and records no decision,
so a state directory shared by many containers collects no grants. Neither the
flag nor the policy has an environment variable, which would reach every child
process.
In an image you build yourself, a [trust policy](#trust-policy) in the global
`init.lua` covers every run without a flag on each command:
```lua
maki.setup({
trust = { paths = { "/workspace/**" } },
})
```
## What a Yes Covers
Your yes covers the kinds of gated file the folder had that day. A project that
later adds a kind you were never asked about asks again.
Maki records the file names rather than their contents, so Lua that changes in a
later pull runs under the answer you already gave. Run `maki trust remove` when
that stops being what you want.
---
# MCP (Model Context Protocol)
Maki connects to external tool servers over MCP. Both **stdio** and **HTTP** transports are supported.
## Configuration
Add servers under `[mcp.*]` in your MCP config:
- **Global**: `~/.config/maki/mcp.toml`
- **Project**: `.maki/mcp.toml` in the active Git checkout, or in the working
directory outside Git (project config wins when both set a value)
Servers in the project file start only after you trust that folder, see
[Folder Trust](/docs/folder-trust/).
### Stdio
```toml
[mcp.filesystem]
command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
[mcp.github]
command = ["gh", "mcp-server"]
environment = { GITHUB_TOKEN = "ghp_xxxx" }
timeout = 10000
enabled = false
```
### HTTP
```toml
[mcp.analytics]
url = "https://mcp.example.com/mcp"
headers = { Authorization = "Bearer ${ANALYTICS_TOKEN}" }
```
`headers` and `environment` values expand `${VAR}` from the environment. A
referenced variable that is unset or empty fails that server with the variable
named in its status, instead of sending a dangling `Bearer ` and getting a 401.
Some HTTP servers need OAuth but have no dynamic client registration. For those, give Maki a static client:
```toml
[mcp.acme]
url = "https://mcp.acme.example.com/mcp"
oauth = { client_id = "acme-client", client_secret = "s3cret", callback_port = 3118, callback_path = "/callback", callback_hostname = "localhost" }
```
### All options
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `command` | array | | Stdio: program + args |
| `url` | string | | HTTP: server URL |
| `environment` | map | | Stdio only. Values expand `${VAR}` from the environment |
| `headers` | map | | HTTP only. Values expand `${VAR}` from the environment |
| `oauth` | table | | HTTP only: static client (`client_id`, optional `client_secret`, optional `callback_port`, optional `callback_path`, optional `callback_hostname`) |
| `timeout` | u64 | 30000 | Milliseconds (1-300000) |
| `enabled` | bool | true | |
| `always_load` | bool | false | Skip tool search, load all tools upfront |
Set `command` for stdio, `url` for HTTP. Pick one.
One option lives at the top level of `mcp.toml`, outside any server:
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `defer_tools` | usize | 10 | Defer tools only when more than this many exist |
## Tool search
Every tool definition a server exposes costs context window space, on every request. Take Datadog's MCP server: with all toolsets on it ships over 100 tools, when a task often needs three.
So Maki, like Claude Code, defers MCP tools by default. The model sees one small `tool_search` tool that lists the deferred names, searches when it actually needs something, and the matches stay loaded for the rest of the session. Resume a session and the tools it was using come back. Subagents keep their own loads, so their searches don't bloat your main conversation.
```
server ships 117 tool definitions
│
more than defer_tools (10)?
│ no │ yes
▼ ▼
all load context gets one small tool: tool_search
upfront │
│ model: tool_search("logs")
▼
3 matches load, stay for the session
114 definitions never enter context
```
You don't configure anything for this. Add the server as usual:
```toml
[mcp.datadog]
url = "https://mcp.datadoghq.com/api/unstable/mcp-server/mcp?toolsets=all"
```
Ask about an incident, and the model searches for something like `datadog logs`, gets back the few matching tools, and the other hundred definitions never enter the conversation.
With 10 or fewer tools across all your servers there is no search step: at that size, searching costs more than it saves, so everything loads upfront. The top-level `defer_tools` key moves that line:
```toml
defer_tools = 30
[mcp.github]
url = "https://api.githubcopilot.com/mcp/"
```
Set it to 0 to always defer, or above your tool count to never defer.
If one server should skip the search step entirely, opt it out:
```toml
[mcp.linear]
command = ["linear-mcp-server"]
always_load = true
```
Good for small servers you rely on every turn. On a big server it defeats the point: every definition is back in your context on every request.
## Naming and namespacing
Server names are ASCII alphanumeric, hyphens ok (no dots). Tools get prefixed with their server name: a `read` tool on the `filesystem` server becomes `filesystem__read`. Because of this, `__` is reserved and names can't collide with built-in tools.
Permission rules for MCP tools use the same nested form under `[mcp.]` in `permissions.toml`. See [Permissions](/docs/permissions/#mcp-tool-permissions).
## Runtime toggling
Open the MCP picker with `/mcp`. Turn servers on or off there; changes save back to your config (project or global, depending on which file defined the server).
## Status
| Status | Meaning |
|--------|---------|
| Connecting | Waiting for the server to come up |
| Running | Tools available |
| Disabled | Off in config or toggled off in UI |
| Failed | Error shown in UI |
| NeedsAuth | Waiting for OAuth (see below) |
If one server fails, the rest still work.
## OAuth
Some HTTP servers need auth. When that happens, Maki opens your browser to log in. Other servers keep working while you authenticate. Tokens refresh on their own. If you change the server URL, you log in again.
```bash
maki mcp auth # manually trigger auth
maki mcp logout # remove stored tokens
```
Servers without dynamic client registration need a client you registered yourself (e.g. your own app on their platform). Add it to the server config so the auth flow uses it instead of trying to register:
| Field | Type | Notes |
|-------|------|-------|
| `client_id` | string | Client id of your registered app |
| `client_secret` | string | Optional, for confidential clients |
| `callback_port` | u16 | Optional, pins the loopback port so the redirect URI can be pre-registered |
| `callback_path` | string | Optional, loopback path of the redirect URI (default `/mcp/oauth/callback`) |
| `callback_hostname` | string | Optional, loopback hostname of the redirect URI (default `127.0.0.1`) |
Set `callback_port` when the server only accepts exact redirect URIs. Otherwise Maki falls back to its default port, then to any free port, so the redirect URI changes between runs. Set `callback_path` when the server registered a different path (e.g. `/callback`). Set `callback_hostname` to `localhost` when the server registered the name form instead of the IP (the listener still binds to 127.0.0.1).
### Headless machines
On a machine without a browser (say, a dev server over SSH), run `maki mcp auth `. Maki prints the login URL. Open it on your laptop and log in. The browser lands on a `http://127.0.0.1:19876/...` page that fails to load. Copy that full URL from the address bar and paste it into the terminal to finish the login.
## Prompts
MCP servers can expose prompts (reusable message templates). Maki shows them as slash commands in the command palette: `/server:prompt-name`. Type `/` to filter.
```
/github:create-pr # no arguments
/analytics:report monthly # one argument
/review:code src tests # multiple, positional
```
Skip a required argument and Maki shows a usage hint. Prompts are fetched at startup and on reconnect, so new ones need a restart. Only text content is supported.
---
# Commands
Type `/` in the input box to open the command palette.
## Built-in commands
| Command | Description |
|---------|-------------|
| `/compact` | Summarize and compact conversation history (optional guidance) |
| `/new` | Start a new session |
| `/help` | Show keybindings |
| `/usage` | Show token usage breakdown |
| `/queue` | Remove items from queue |
| `/model` | Switch model |
| `/theme` | Switch color theme |
| `/mcp` | Configure MCP servers |
| `/login` | Authenticate with an LLM provider |
| `/cd` | Change working directory |
| `/btw` | Ask a quick question (no tools, no history pollution) |
| `/yolo` | Toggle YOLO mode (skip all permission prompts) |
| `/fast` | Toggle fast mode (Anthropic Opus or Codex subscription models) |
| `/workflow` | Toggle workflow mode (task callable inside code_execution) |
| `/exit` | Exit the application |
| `/reload` | Reload plugins and config |
| `/trust` | Trust this folder and load its shared project config |
| `/packupdate` | Update packages (++lockfile, ! skips review) |
| `/packdel` | Remove undeclared packages (++all, or a name) |
| `/memory` | View, edit, and delete memory files |
| `/rename` | Rename the current session |
| `/sessions` | Browse and switch sessions |
| `/tasks` | Browse and search tasks |
| `/thinking` | Extended thinking: pick an effort level, or set one directly |
## Sessions
Sessions run concurrently. `/new` starts a fresh session while the old one keeps working in the background, and `/sessions` shows the live status of each (working, needs input, idle) so you can jump between them. When a background session finishes or needs input, Maki flashes a note in the status bar. `/rename` renames the current session; in the session picker, `Ctrl+N` / `Ctrl+R` / `Ctrl+D` create, rename, and delete.
## Modes and toggles
- **`/yolo`**: skip permission prompts for this session (deny rules still apply). The toggle survives a resume, and `--yolo` only sets the starting value. Config: `always_yolo = true`.
- **`/thinking`**: extended thinking. Bare, or `Alt+T`, it opens a picker of the effort levels with what each one costs in tokens; `Enter` applies the selected level and `Esc` closes without changing anything. With an argument it sets the level directly: `off`, `adaptive`, an effort level (`minimal` … `max`), or a token budget number. Config: `always_thinking`.
- **`/fast`**: faster responses on Anthropic Opus, and on eligible Codex models when you sign in with a ChatGPT subscription. OpenAI API keys and every other model ignore it. Config: `always_fast = true`.
- **`/workflow`**: let `code_execution` call the `task` tool (and other workflow-only tools) from inside the Python sandbox. Config: `always_workflow = true`.
- **Plan / build**: not a slash command. Press `Tab` in the input to toggle plan mode (plan-file writes only).
- **`/reload`**: rebuild plugins and config without leaving the app.
- **`/btw`**: one-shot side question with no tools and no history pollution.
- **`/memory`**: open the memory file picker (view / edit / delete). See the `memory` tool under [Tools](/docs/tools/).
## Custom commands
You can define your own slash commands as Markdown files. Empty files are skipped.
### Discovery and priority
Later sources override earlier ones when the command **name** matches (the stem of the file, or `name` in frontmatter):
1. User config: `~/.config/maki/commands/` (and legacy `~/.maki/commands/` if present)
2. User third-party: `~/.claude/commands/`
3. Project dirs, walking from the current working directory up to the nearest `.git` root. At each level: `.maki/commands/`, then `.claude/commands/`
Because the walk goes cwd → … → git root, a command at the **repository root overrides** the same name found only under a nested cwd. Project commands override user commands. Palette names are `/project:` or `/user:` depending on which scope won.
Skip all of the above with `--no-commands` (see [CLI](/docs/cli/)).
### Metadata
You can add optional metadata at the top of the file between `---` lines to set `name`, `description`, and `argument-hint`:
```markdown
---
description: Review code for issues
argument-hint:
---
Review $ARGUMENTS and suggest improvements.
```
### Arguments
Use `$ARGUMENTS` in the command body. It gets replaced with whatever you type after the command name. The command is treated as accepting args if the body contains `$ARGUMENTS` or you set `argument-hint`.
For example, `/project:review main.rs` replaces `$ARGUMENTS` with `main.rs`.
## Aliasing commands
Prefer a different name for a command? `maki.api.run_command` runs any slash command exactly as typing it would, so an alias is a one-line handler in your `init.lua` instead of a reimplementation.
```lua
-- ~/.config/maki/init.lua
local aliases = {
{ name = "/clear", target = "/new", description = "Alias for /new" },
{ name = "/resume", target = "/sessions", description = "Alias for /sessions" },
}
for _, alias in ipairs(aliases) do
maki.api.register_command({
name = alias.name,
description = alias.description,
handler = function()
local ok, err = maki.api.run_command(alias.target)
if not ok then
maki.ui.flash("could not run " .. alias.target .. ": " .. err)
end
end,
})
end
```
Both names stay in the palette: aliasing adds a name, it does not rename or hide the original. It works for any command listed above, plus plugin commands and MCP prompts. See [`maki.api.run_command`](/docs/lua-api/#maki-api-run_command) for matching and error handling, or [`maki.ui.action`](/docs/lua-api/#maki-ui-action) to bind a key instead of a name.
Related: [CLI](/docs/cli/) for shell flags and subcommands, [Skills](/docs/skills/) for on-demand playbooks.
---
# Keybindings
On macOS, some bindings use Option or Fn keys instead (run `/help` for exact keybindings).
## General
| Key | Action |
|-----|--------|
| `Ctrl+C` | Quit / clear input |
| `Ctrl+H` | Show keybindings |
| `Ctrl+F` | Search messages |
| `Ctrl+S` | File picker |
| `Ctrl+O` | Open plan in editor |
| `Ctrl+T` | Toggle plan panel |
| `Ctrl+M` | Model picker |
## Editing
| Key | Action |
|-----|--------|
| `Enter` | Submit prompt |
| `Shift+Enter` / `Ctrl+Enter` / `Ctrl+J` / `Alt+Enter` | Newline |
| `Tab` | Toggle mode |
| `/command` | Open command palette |
| `Ctrl+W` | Delete word backward |
| `Alt+←` / `Alt+→` | Move word left / right |
| `Ctrl+A` | Jump to start of line |
| `Home` / `End` | Jump to start/end of line |
| `Ctrl+U` / `Ctrl+D` | Scroll half page up / down |
| `PageUp` / `PageDown` | Scroll page up / down |
| `Ctrl+E` | Jump to end of line |
| `Ctrl+G` | Scroll to top |
| `Ctrl+B` | Scroll to bottom |
| `Ctrl+Q` | Pop queue |
| `Esc Esc` | Rewind |
| `Alt+O` | Edit input in external editor |
### macOS-specific
| Key | Action |
|-----|--------|
| `Ctrl+Del` / `⌥Del` | Delete word forward |
| `Ctrl+K` | Delete to end of line |
## While Streaming
| Key | Action |
|-----|--------|
| `↑` / `↓` | Navigate input history |
| `Esc Esc` | Cancel agent |
## Form
| Key | Action |
|-----|--------|
| `↑` / `↓` | Navigate options |
| `Enter` | Select option |
| `Esc` | Close |
## Pickers
| Key | Action |
|-----|--------|
| `↑` / `↓` | Navigate |
| `Enter` | Select |
| `Esc` | Close |
| `Type` | Filter |
| `PageUp` / `PageDown` | Scroll page up / down |
| `Ctrl+U` / `Ctrl+D` | Scroll half page up / down |
## Context-Specific
Some pickers add extra bindings on top of the defaults:
| Context | Key | Action |
|---------|-----|--------|
| Queue | `Enter` | Remove item |
| Commands | `Tab` | Complete command |
| Model Picker | `!/@/#/$` | Set tier (strong/medium/weak/compaction) |
| Session Picker | `Ctrl+N` | New session |
| Session Picker | `Ctrl+R` | Rename session |
| Session Picker | `Ctrl+D` | Delete session (press twice) |
| Thinking Picker | `↑`/`↓` | Move between effort levels |
| Thinking Picker | `0`-`9` | Type a token budget |
| Thinking Picker | `Enter` | Apply and close |
| Thinking Picker | `Esc` | Close without changing anything |
## Plugins
Built-in plugins register these themselves, and your own plugins can add more with `maki.keymap.set`:
| Key | Action |
|-----|--------|
| `Ctrl+P` | Browse sessions |
| `Ctrl+X` | Open tasks |
| `Alt+T` | Thinking effort |
## Context Inheritance
Child contexts inherit their parent's bindings and add their own.
- **Pickers** is the base for: Rewind Picker, Theme Picker, Model Picker, Queue, Commands, Search, File Picker
## Overriding Keybindings
Plugins and `init.lua` can rebind keys at runtime with `maki.keymap.set` and `maki.keymap.del`. The tables above are the built-in defaults. An override on the same key wins, unless a modal or overlay is open (help, plan form, permission prompt).
Precedence, high to low:
1. **Suspend** (`Ctrl+Z`, Unix). Always wins, non-remappable.
2. **Modal and overlay keys.** An open modal or picker consumes its keys first, so they cannot be shadowed while open.
3. **Lua overrides** from `maki.keymap.set`. Last set wins; binding the same key twice warns.
4. **Built-in defaults.** An override on the same key shadows them; `maki.keymap.del` lifts the override so the default returns. Suspend is the only binding outside this layer, so every key is remappable except `Ctrl+Z`.
Only single-key bindings can be overridden. Multi-key combinations and non-key rows (like `Type` to filter) cannot.
The `/help` modal and the splash show default labels, not live overrides, but pressing the key still runs the override.
### Recovering from a bad keymap
If an override leaves Maki stuck (a rebound `Ctrl+C`, a modal that won't close, a plugin that throws on load), boot without user `init.lua`:
```bash
maki --no-plugins
```
Skips user `init.lua` files (global and project) but keeps the Lua host and builtin plugins running, so tools still work. Custom commands and skills still load, and the project permission rules and env file follow [folder trust](/docs/folder-trust/).
The default keymap lives in Rust, not Lua, so `--no-plugins` never drops it.
## Shell and images
These are input conventions, not remappable key rows:
- Prefix a line with `!` to run a shell command yourself (5 minute timeout). Use `!!` to hide the command and its output from the agent.
- `Ctrl+V` pastes an image from the clipboard into the prompt when the model supports vision. You can also paste image file paths.
---
# Notifications
Maki can tell you when a session finishes or needs your input. This is useful
when you move to another terminal while Maki works.
Notifications are enabled by default. A prompt that waits on you always
notifies, because the agent stays parked until you answer. `Agent turn
complete` is skipped only when Maki can tell you are watching: the terminal
reported focus, or you typed in the last 30 seconds.
Maki uses these messages:
- `Agent turn complete` or a preview of the response, up to 200 characters.
- `Permission requested: ` for a permission prompt.
- `Authentication required` when authentication needs attention.
- `Question requested` for a question prompt.
- `Plan ready` when a plan is ready.
Response previews can appear in your operating system's notification history.
Maki does not include tool arguments, permission scopes, question bodies, plan
content, or error details. Use `bell` for a message-free alert, or use `off`
to disable notifications if response text should not reach notification
history.
## Configuration
Set `ui.notifications` in `~/.config/maki/init.lua`:
```lua
maki.setup({
ui = {
notifications = "auto",
},
})
```
| Value | Behavior |
| --- | --- |
| `auto` | Use OSC 9 in a supported terminal. Use BEL otherwise. |
| `osc9` | Always send an OSC 9 notification. |
| `bell` | Always send the terminal bell. |
| `off` | Do not send notifications. |
`auto` supports Ghostty, iTerm2, Kitty, Warp, and WezTerm. An unknown terminal
uses BEL. Your terminal settings decide whether BEL makes a sound or shows a
visual alert.
Maki also recognizes `xterm-ghostty` and `xterm-kitty` from `TERM`. This lets
OSC 9 work when an SSH connection does not preserve `TERM_PROGRAM`.
## tmux
OSC 9 needs passthrough:
```tmux
set -g allow-passthrough all
```
Use `allow-passthrough all`, not `allow-passthrough on`. The `on` value permits
passthrough only while the Maki pane is visible. tmux drops the notification
after you change to another tmux window.
Focus events are a separate setting:
```tmux
set -g focus-events on
```
This lets Maki suppress a turn completion you are already watching. Without it
Maki falls back to your last keypress and notifies for anything slower than 30
seconds.
Add the settings to `~/.tmux.conf`, then reload the file or restart tmux.
## Other terminal multiplexers
Maki wraps OSC 9 for GNU screen. GNU screen does not pass terminal focus
events to Maki, so Maki does not suppress notifications there. A notification
can appear while the GNU screen window has focus.
Maki sends OSC 9 directly through Zellij.
## Focus on Windows
This terminal focus protocol is not available on Windows. Maki treats the
terminal as unfocused so an explicit `bell` or `osc9` setting still works.
---
# Lua API
Maki plugins are plain Lua files. Everything a plugin can touch lives under
one global table: `maki`. This reference documents every module, function,
and method. It is generated straight from the source code by `maki-docgen`.
For where plugin files live and how to load them, read the
[Plugins guide](/docs/plugins/) first.
The API tries to mirror Neovim as much as possible (`maki.fs`, `maki.uv`,
`maki.treesitter`, `maki.keymap`, `maki.base64`), signatures are kept identical
so code can be copy-pasted between the two without too many modifications.
Plugins run compiled to native code (Luau JIT). If you are debugging a
plugin and want full backtraces, start maki with `--no-jit`: it runs your
Lua on the interpreter with complete debug info instead.
A small plugin looks like this:
```lua
maki.api.register_command({
name = "greet",
description = "Say hello from Lua",
handler = function()
maki.ui.flash("hello from a plugin!")
end,
})
```
## How to read this reference
Signatures use Neovim notation: `{path}` is a required argument, `{opts?}`
is optional, and `{...}` is variadic.
One convention to remember: fallible runtime operations return a
`(value, err)` pair instead of throwing. Check `err` before using `value`:
```lua
local text, err = maki.fs.read("config.json")
if err then
maki.log.error("read failed: " .. err)
return
end
```
Lua errors are reserved for programmer mistakes, like passing a number where
a string belongs.
## Permissions and plugin.toml {#plugin-permissions}
Sensitive APIs are gated per plugin file, and every gated function's entry in
this reference names the permission it needs. A gated call without its
permission raises `permission denied: '' not granted for this plugin`.
- `fs_read`: reading files, and locating the directories maki keeps them in
- `fs_write`: creating, changing, and removing files
- `net`: outbound network requests
- `run`: starting processes
- `env`: reading the process environment, where secrets live
Grants come from a `plugin.toml` next to the Lua file (for
`~/.config/maki/init.lua` that is `~/.config/maki/plugin.toml`):
```toml
min_maki_version = "0.4.12"
[permissions]
fs_read = true
fs_write = true
net = true
run = true
env = true
```
The rules:
- No `plugin.toml` at all: every permission is denied, and maki logs a
warning at load time.
- `plugin.toml` exists: permissions default to granted; set a key to
`false` to revoke it. An empty file grants everything.
- Invalid TOML: everything denied, with a warning in the log.
- A package, or a plugin maki ships, is read the other way round: a key it
does not name is not requested, so its `plugin.toml` lists everything it
uses. Only a `plugin.toml` you wrote yourself defaults to granted.
- `min_maki_version` is optional and takes a plain semantic version as a lower
bound, so ranges do not work. When the field is invalid or the running
version is older, Maki skips the Lua in that directory and warns at startup
instead of failing. The same floor applies to an installed package, which is
skipped while the rest keep loading. `--no-plugins` still skips every user
plugin at once.
## Overview
| Module | What it is for |
| --- | --- |
| [`maki`](#maki) | The global entry point. |
| [`maki.pack`](#maki-pack) | Declare global packages and inspect package state. |
| [`maki.api`](#maki-api) | Plugin registration. |
| [`maki.agent`](#maki-agent) | Subagent primitives for plugins that need to talk to an LLM. |
| [`maki.agent.Session`](#maki-agent-Session) | A subagent session with its own conversation history. |
| [`maki.async`](#maki-async) | Tools for running things concurrently in Lua plugins. |
| [`maki.async.Semaphore`](#maki-async-Semaphore) | A counting semaphore for limiting how many tasks run at once. |
| [`maki.async.Permit`](#maki-async-Permit) | One slot in a semaphore, obtained from `Semaphore:acquire()`. |
| [`maki.base64`](#maki-base64) | Base64 encoding and decoding, modelled after `vim.base64`. |
| [`maki.env`](#maki-env) | Paths to maki's own directories (config, state, logs, legacy). |
| [`maki.fn`](#maki-fn) | Process and environment helpers, modeled after Neovim's `vim.fn` job |
| [`maki.fs`](#maki-fs) | File-system utilities, modelled after `vim.fs` and `vim.uv`. |
| [`maki.image`](#maki-image) | Small building blocks for working with images: probe metadata, decode |
| [`maki.image.Image`](#maki-image-Image) | A decoded image you can inspect, resize, and re-encode. |
| [`maki.interpreter`](#maki-interpreter) | Run Python code in a memory-safe, time-limited sandbox. |
| [`maki.json`](#maki-json) | JSON encoding, decoding, and schema validation. |
| [`maki.json.SchemaValidator`](#maki-json-SchemaValidator) | A compiled JSON Schema validator. |
| [`maki.keymap`](#maki-keymap) | Key mappings, modeled after `vim.keymap`. |
| [`maki.log`](#maki-log) | Structured logging for plugins. |
| [`maki.model`](#maki-model) | The model behind the focused session. |
| [`maki.net`](#maki-net) | HTTP client for fetching web content. |
| [`maki.session`](#maki-session) | Host session primitives. |
| [`maki.Timer`](#maki-Timer) | Handle returned by `maki.defer_fn`. |
| [`maki.task`](#maki-task) | The subagents of the focused session and their transcripts. |
| [`maki.text`](#maki-text) | Text transformation utilities. |
| [`maki.treesitter`](#maki-treesitter) | Tree-sitter parsing and query API. |
| [`maki.treesitter.language`](#maki-treesitter-language) | Language registry for tree-sitter grammars. |
| [`maki.treesitter.query`](#maki-treesitter-query) | Query compilation and lookup. |
| [`maki.treesitter.Query`](#maki-treesitter-Query) | A compiled tree-sitter query. |
| [`maki.treesitter.Tree`](#maki-treesitter-Tree) | A parsed syntax tree. |
| [`maki.treesitter.Node`](#maki-treesitter-Node) | A single node in a parsed syntax tree. |
| [`maki.treesitter.LanguageTree`](#maki-treesitter-LanguageTree) | Manages parsing of a source string for a single language. |
| [`maki.ui`](#maki-ui) | Functions for building interactive UI. |
| [`maki.ui.Win`](#maki-ui-Win) | Handle to a floating or split window. |
| [`maki.ui.Buf`](#maki-ui-Buf) | A content buffer that holds styled lines of text. |
| [`maki.uv`](#maki-uv) | System and environment utilities, modelled after `vim.uv`. |
| [`maki.yaml`](#maki-yaml) | YAML encoding and decoding. |
## maki {#maki}
The global entry point. Every API lives under this table.
---
### `maki.setup()` {#maki-setup}
```lua
maki.setup({config})
```
Apply your personal configuration. This is only available inside `init.lua` (not in plugins) and can be called at most once. The table accepts the same keys as the Configuration reference.
**Parameters:**
- `{config}` (`table`) Configuration table.
**Example:**
```lua
maki.setup({
model = "opus",
keymaps = false,
})
```
---
### `maki.split()` {#maki-split}
```lua
maki.split({s}, {sep}, {opts?})
```
Split {s} at each occurrence of {sep} and return the pieces as a
list. Mirrors Neovim's `vim.split`, so code using it can be copied
between Neovim and maki. {sep} is a Lua pattern unless `plain` is
set; an empty {sep} splits into single characters.
**Parameters:**
- `{s}` (`string`) String to split.
- `{sep}` (`string`) Separator: a Lua pattern, or literal text with `plain`.
- `{opts?}` (`table?`) Optional settings:
- `plain` (`boolean?`) treat {sep} as literal text instead of a pattern.
- `trimempty` (`boolean?`) drop empty pieces from the start and end of the result.
**Returns:** (`table`) List of split pieces.
**Example:**
```lua
maki.split("a,b,c", ",") -- { "a", "b", "c" }
maki.split("x*y*z", "*", { plain = true }) -- { "x", "y", "z" }
maki.split("\nhello\nworld\n", "\n", { trimempty = true }) -- { "hello", "world" }
```
---
### `maki.packadd()` {#maki-packadd}
```lua
maki.packadd({name})
```
Load an installed package that is not active.
**Parameters:**
- `{name}` (`string`) Package name.
---
### `maki.defer_fn()` {#maki-defer_fn}
```lua
maki.defer_fn({callback}, {ms})
```
Run {callback} after {ms} milliseconds, on the Lua thread and outside
any task scope. The timer does not hang off the caller's cancel token
or the 60 second `async.run` deadline, so the callback still fires
once the tool call that scheduled it is over. That is what a toast
needs to dismiss itself, and the difference from `maki.async.sleep`.
You get back a handle. Its `:stop()` cancels a callback that has not
fired yet, which is how you debounce: schedule, then stop and
reschedule on every new event. An error raised by the callback is
logged and dropped, since nobody is waiting for a result.
**Parameters:**
- `{callback}` (`function`) Called with no arguments.
- `{ms}` (`integer`) Delay in milliseconds. Zero fires on the next tick.
**Returns:** ([`maki.Timer`](#maki-Timer)) Handle with `:stop()` to cancel before it fires.
**Example:**
```lua
-- A toast that dismisses itself 4 seconds later:
local buf = maki.ui.buf({ scratch = true })
buf:line("copied!")
local win = maki.ui.open_win(buf, { split = "right", width = 20, height = 3 })
maki.defer_fn(function() win:close() end, 4000)
-- Repaint only after the user has stopped typing for half a second:
local pending
local function repaint_soon()
if pending then
pending:stop()
end
pending = maki.defer_fn(repaint, 500)
end
```
---
### `maki.notify()` {#maki-notify}
```lua
maki.notify({msg}, {level?}, {opts?})
```
Show a one line notice. By default it goes to `maki.ui.flash`, with
`{opts.title}` in front of the message when you pass one. A run with
no UI, such as `maki -p` or the sdk, logs the notice instead of
dropping it.
There is one handler for the whole process. Once a plugin calls
`maki.set_notify_handler`, notices from every plugin go through it.
That is how a UI plugin turns flashes into stacked toasts without
any of the callers knowing about it.
{level} reaches the handler untouched, and the default ignores it.
**Parameters:**
- `{msg}` (`string`) Notice text.
- `{level?}` (`string?`) Optional. Severity name such as "info", "warn" or "error".
- `{opts?}` (`table?`) Optional. `title` (string) labels the notice. Free form otherwise.
**Example:**
```lua
maki.notify("saved!")
maki.notify("build failed", "error", { title = "make" })
```
---
### `maki.set_notify_handler()` {#maki-set_notify_handler}
```lua
maki.set_notify_handler({handler})
```
Install the handler that every `maki.notify` call in the process goes
through, in place of the default flash. Pass `nil` to put the default
back.
The handler runs on the Lua thread, so keep it short and hand real
work to `maki.async.run`. If it raises an error, the error is logged
and the notice falls back to `maki.ui.flash`, so the user still sees
it. Unloading the plugin that installed the handler also restores the
default.
**Parameters:**
- `{handler}` (`function|nil`) Handler `function(msg, level?, opts?)`, or nil.
**Example:**
```lua
local Toast = require("maki.toast")
maki.set_notify_handler(function(msg, level, opts)
Toast.show(msg, { title = opts and opts.title, level = level })
end)
```
## maki.pack {#maki-pack}
Declare global packages and inspect package state.
`add` is available only in the global `init.lua`. `get` is read-only
and is available in project config and packages.
---
### `maki.pack.add()` {#maki-pack-add}
```lua
maki.pack.add({specs}, {opts?})
```
Declare global packages after the global `init.lua` finishes.
**Parameters:**
- `{specs}` (`table`) Sources or tables with `src`, `name`, `version`, and `data`.
- `{opts?}` (`table?`) `confirm` controls source confirmation. `load` is a
boolean or a custom loader function.
**Example:**
```lua
maki.pack.add({
{ src = "https://github.com/user/maki-goal", version = "main" },
})
```
---
### `maki.pack.get()` {#maki-pack-get}
```lua
maki.pack.get({names?}, {opts?})
```
Get package state without changing the installed set.
**Parameters:**
- `{names?}` (`table?`) Package names. Omit for all managed packages.
- `{opts?}` (`table?`) Reserved. Omit it.
**Returns:** (`table`) Package records with `spec`, `path`, `rev`, and `active`.
## maki.api {#maki-api}
Plugin registration. This is where you tell maki about your tools,
slash commands, and prompt contributions.
Most plugins only need `register_tool` and maybe `register_prompt_hint`.
Call these at the top level of your plugin file (during load).
```lua
maki.api.register_tool({ name = "greet", ... })
maki.api.register_prompt_hint({ slot = "tool_usage", content = "..." })
```
---
### `maki.api.register_tool()` {#maki-api-register_tool}
```lua
maki.api.register_tool({spec})
```
Register a new tool the agent can call. This is the main way plugins add
capabilities to the agent. The tool is queued during plugin load and
committed to the registry once the plugin finishes loading.
Your {spec} table must include a name, a description (the model reads it
to decide when to use the tool), a JSON Schema for the input, and a handler
function. The handler receives `(input, ctx)` and returns either a plain
string or a table with richer output fields.
**Parameters:**
- `{spec}` (`table`) Tool specification:
- `name` (`string`) Required. ASCII identifier, up to 64 chars ([a-zA-Z_][a-zA-Z0-9_]*).
- `description` (`string`) Required. Non-empty description shown to the model.
- `schema` (`table`) Required. JSON Schema object describing the tool's input parameters.
- `handler` (`function`) Required. Called with `(input, ctx)` when the tool is invoked.
Must return a string or a table with any of these fields:
- `llm_output` (`string`) Text sent to the model.
- `is_error` (`boolean`) When true, the result is treated as an error.
- `content` (`string`) Alias for llm_output (legacy).
- `body` (`BufHandle`) Rich rendered body shown in the UI.
- `header` (`BufHandle`) One-line header shown before the body.
- `format` (`string`) "plain" (default) or "markdown".
- `annotation` (`string`) Short label shown next to the tool call.
- `written_path` (`string`) Path of a file written by the tool.
- `diff_path` (`string`) Path for a diff output block.
- `diff_before` (`string`) Before text of the diff.
- `diff_after` (`string`) After text of the diff.
- `image` (`table`) { media_type: string, data: string } base64 image.
- `instructions` (`table`) Array of { path, content } blocks injected as context.
- `state` (`any`) Serializable state forwarded to restore.
- `audiences` (`string[]`) Which model audiences see the tool. Values: "main", "sub", "all". Default: all audiences.
- `kind` (`string`) Optional grouping label (e.g. "filesystem").
- `timeout` (`number`) Execution timeout in seconds. 0 or false disables. Default: inherits agent deadline.
- `header` (`function`) Optional. Called before execution, returns a string or BufHandle for the one-line header.
- `restore` (`function`) Optional. Called to re-render a previous tool result. Receives `(tool_name, input, output, ctx)`.
- `start` (`function`) Optional. Called when the tool call starts, before the handler runs.
- `describe` (`function`) Optional. Returns a custom description string for the current context.
- `examples` (`table`) Optional. Array of example input objects for documentation.
- `permission_scopes` (`string|function`) Field name in schema (string) or `function(input)` returning a list of path scopes that need write permission. Declaring it is what puts the tool in front of the permission prompt, and it requires `permission`.
- `permission` (`string`) Required with `permission_scopes`. The capability the tool exposes to the model: "fs_read", "fs_write", "net", "run", or "env". Your plugin must hold it, and so must any plugin that pre-approves this tool.
- `mutable_path` (`string`) Schema field name (type: string) for the primary path the tool writes. Required with `permission = "fs_write"`. Declaring it is what gets the tool, from the dispatcher and never from the handler: serialization of concurrent calls on that file, the stale-read rejection, the plan-mode block, and the permission boundary check.
- `start_annotation` (`string|table`) Schema field used to annotate the start header with a count (string) or timeout (`{ field, kind="timeout" }`).
**Example:**
```lua
maki.api.register_tool({
name = "word_count",
description = "Count words in a file.",
kind = "read",
schema = {
type = "object",
properties = { path = { type = "string", description = "File path" } },
required = { "path" },
},
handler = function(input)
local f = io.open(input.path, "r")
if not f then return { llm_output = "file not found", is_error = true } end
local n = 0
for _ in f:read("*a"):gmatch("%S+") do n = n + 1 end
f:close()
return tostring(n) .. " words"
end,
})
```
---
### `maki.api.register_permission_rule()` {#maki-api-register_permission_rule}
```lua
maki.api.register_permission_rule({spec})
```
Declare an agent permission rule for a native tool. Use it to pre-allow
(or pre-deny) tool calls on paths your plugin owns, like a storage
directory outside the working dir, so the user is not prompted for them.
Rules live as long as the plugin is loaded: a reload replaces them, and a
reload that registers none clears the old ones. User config and session
deny rules always win over a plugin allow.
An allow is delegation, not escalation: it needs the `permission` the
target tool declares, so a plugin can only pre-approve what it could
already do itself. A deny needs no permission.
Allows are checked once the plugin finishes loading, so a plugin may
pre-approve a tool it registers itself. One that does not hold up (no such
tool, a tool with no `permission_scopes` that is never checked, or a
permission the plugin lacks) is dropped with a warning in the log while the
rest of the plugin loads. Without the rule the call simply prompts.
**Parameters:**
- `{spec}` (`table`) Rule specification:
- `tool` (`string`) Required. Native tool name (e.g. "edit", "write").
MCP tools and the "*" wildcard are not allowed.
- `scope` (`string`) Required. Scope pattern the rule applies to, e.g.
"/abs/dir/**" for a directory subtree. An allow whose
pattern matches every scope ("*", "**", "/*", "/**") is
refused: name the paths or commands it covers. A deny may
cover everything.
- `effect` (`string`) Optional. "allow" (default) or "deny".
**Example:**
```lua
maki.api.register_permission_rule({
tool = "write",
scope = notes_dir .. "/**",
})
```
---
### `maki.api.register_command()` {#maki-api-register_command}
```lua
maki.api.register_command({spec})
```
Register a slash-command that appears in the user input bar.
Slash commands let the user trigger plugin actions by typing `/name` in the
input. Use them for interactive workflows that do not need the model, like
browsing memory files or toggling settings.
**Parameters:**
- `{spec}` (`table`) Command specification:
- `name` (`string`) Required. The command name (e.g. "/hello"; a leading
slash is added when missing).
- `description` (`string`) Optional. Short description shown in the command palette.
- `nargs` (`integer|string`) Optional. How many arguments the command
takes, spelled like nvim's nargs: 0 (default),
1, "?" (zero or one), "*" (any number), or "+"
(one or more). An argument is a whitespace
separated word. Type more than allowed and the
command quietly stops matching: the input goes
to the model as a normal message. Only the upper
bound is checked, so with "+" you still need to
handle an empty `opts.args` yourself.
- `handler` (`function`) Required. Called when the user runs the command,
with one opts table: `opts.args` is the raw
argument string (whitespace kept, may be empty)
and `opts.fargs` is the same split into words.
**Example:**
```lua
maki.api.register_command({
name = "/hello",
description = "Say hello",
handler = function()
maki.ui.flash("Hello from my plugin!")
end,
})
```
---
### `maki.api.register_prompt_hint()` {#maki-api-register_prompt_hint}
```lua
maki.api.register_prompt_hint({spec})
```
Add a piece of text to an aggregate prompt slot. Multiple plugins can each
contribute to the same slot, and all contributions are concatenated.
Good for things like tool usage guidelines or extra context that should
appear alongside other plugins' hints. If you need to own the whole slot
(e.g. identity or tone), use `set_prompt` instead.
Throws if you pass a singleton slot name.
**Parameters:**
- `{spec}` (`table`) Hint specification:
- `slot` (`string`) Required. Aggregate slot name (e.g. "tool_usage", "general").
- `content` (`string|function`) Required. Static text, or a `function()` that returns a string. Max 1 MiB.
- `prompt` (`string|string[]`) Optional. Restrict to specific prompt ids (e.g. "system").
**Example:**
```lua
maki.api.register_prompt_hint({
slot = "tool_usage",
content = "- Prefer **grep** over reading entire files.",
})
```
---
### `maki.api.register_options()` {#maki-api-register_options}
```lua
maki.api.register_options({spec})
```
Declare the options your plugin accepts under `plugins.` in
`maki.setup`, and get back what the user set merged with your defaults.
Call it once, at the top level of your plugin file.
An unknown key, a wrong type, or a value below `min` fails the plugin
load with a clear message, so users catch typos right away. Bad specs
fail the load too. The specs also feed the generated configuration docs.
**Parameters:**
- `{spec}` (`table`) Map of option name to a spec table:
- `default` (`boolean|number|string`) Optional. Used when the user sets nothing. Its Lua type becomes the option type.
- `type` (`string`) Required when there is no default: "boolean", "integer", "number", or "string".
- `min` (`number`) Optional. Minimum accepted value, numeric options only.
- `desc` (`string`) Required. One line shown in the configuration docs.
**Returns:** (`table`) Merged options: the user's value where set, otherwise the default, or nil when neither exists.
**Example:**
```lua
local opts = maki.api.register_options({
timeout_secs = { default = 120, min = 5, desc = "Kill the command after this many seconds." },
max_output_lines = { type = "integer", desc = "Override agent.max_output_lines for this tool." },
})
```
---
### `maki.api.set_prompt()` {#maki-api-set_prompt}
```lua
maki.api.set_prompt({spec})
```
Set a singleton prompt slot. Only one plugin owns each singleton slot at a
time, so calling this replaces any previous value from your plugin.
Use this for slots like "identity" or "tone" where a single coherent value
makes more sense than combining fragments. For aggregate slots like
"tool_usage", use `register_prompt_hint` instead.
Throws if you pass an aggregate slot name.
**Parameters:**
- `{spec}` (`table`) Spec fields mirror `register_prompt_hint`:
- `slot` (`string`) Required. Singleton slot name (e.g. "identity", "tone").
- `content` (`string|function`) Required. Static text or a `function()` returning a string. Max 1 MiB.
- `prompt` (`string|string[]`) Optional. Restrict to specific prompt ids.
**Example:**
```lua
maki.api.set_prompt({
slot = "tone",
content = "Be concise. No filler words.",
})
```
---
### `maki.api.get_tools()` {#maki-api-get_tools}
```lua
maki.api.get_tools({opts?})
```
Return a list of all registered tools. Useful for building UI that shows
available tools or for checking which tools are enabled.
Each entry has the tool's name, schema, audiences, and an `enabled` flag.
Describe callbacks are not invoked (the static description is used).
**Parameters:**
- `{opts?}` (`table?`) Options:
- `config` (`table`) Optional config table with a `disabled_tools` string[] field used to compute the `enabled` flag on each entry.
**Returns:** (`table[]`) Array of tool entries: { name, schema, audiences, kind?, enabled }.
**Example:**
```lua
local tools = maki.api.get_tools()
for _, t in ipairs(tools) do
print(t.name, t.enabled)
end
```
---
### `maki.api.get_tool()` {#maki-api-get_tool}
```lua
maki.api.get_tool({name})
```
Look up a single tool by name. Returns its metadata table or nil if the
tool does not exist. For Lua-registered tools the returned table also
includes `header` and `restore` handle functions (wrapped so they never
throw).
**Parameters:**
- `{name}` (`string`) Exact tool name.
**Returns:** (`table|nil`) Tool entry with fields { name, schema, audiences, kind?, header?, restore? }, or nil if not found.
**Example:**
```lua
local t = maki.api.get_tool("bash")
if t then
print("bash audiences:", table.concat(t.audiences, ", "))
end
```
---
### `maki.api.run_command()` {#maki-api-run_command}
```lua
maki.api.run_command({cmdline})
```
Runs a slash command by name, exactly as typing it in the input would.
Works for built-ins, custom `/project:` and `/user:` commands, MCP
prompts, and commands other plugins registered.
Use it to alias a command you like under a name you prefer, instead of
reimplementing what it does. See `maki.ui.action` for the same idea
applied to keybound UI actions.
Pass the whole command line, arguments included: `"/cd ~/src"`. The
leading slash is optional. Names match exactly apart from case, so a typo
reports an error instead of running the closest command, and a cycle of
aliases stops with one too.
This returns as soon as the command has been dispatched, not when it
finishes, so aliasing something long-running like `/compact` does not
block your handler.
**Parameters:**
- `{cmdline}` (`string`) Command line, e.g. `"/new"` or `"/cd ~/src"`.
**Returns:** (`boolean|nil`, `string|nil`) `true` once dispatched, or nil and an error message for an unknown command.
**Example:**
```lua
-- /resume as an alias for the built-in session picker:
maki.api.register_command({
name = "/resume",
description = "Alias for /sessions",
handler = function()
local ok, err = maki.api.run_command("/sessions")
if not ok then
maki.ui.flash("could not run /sessions: " .. err)
end
end,
})
```
---
### `maki.api.create_autocmd()` {#maki-api-create_autocmd}
```lua
maki.api.create_autocmd({event}, {opts})
```
Listen for one or more events. Returns an id you can pass to
`del_autocmd` later to remove the listener.
Built-in events fired by the host: `"TurnStart"`, `"TurnEnd"`,
`"TurnError"`, `"ToolStart"`, `"ToolDone"`, `"AutoCompacting"`,
`"CompactionDone"`, `"PlanReady"`, `"SessionReset"`, `"SessionEnd"`,
`"SessionFocusChanged"`, `"SessionStatusChanged"`, `"TaskStatusChanged"`,
`"TaskFocusChanged"`, and `"ModelChanged"`. Plugins can also fire their
own events with `exec_autocmds`.
Every host event carries `data.session_id`. For `"SessionReset"` and
`"SessionEnd"` that is the session being left behind, the other events
name the session now running or focused. What each event adds:
- `"ToolStart"`, `"ToolDone"`: `data.tool_id` and `data.tool`.
- `"TurnEnd"`: `data.reason` (`"finished"`, `"max_tokens"`,
`"max_turns"`, or `"cancelled"`), `data.usage` (four token fields,
cache included), `data.cost`, `data.list_cost`, `data.context_size`,
`data.context_window`, and `data.num_turns` (model round-trips the
turn took). `list_cost` is the un-subsidised list price and `cost` is
the real bill, so a budget plugin charges against whichever one it
wants.
- `"AutoCompacting"`: `data.context_size` and `data.context_window` at
trigger time.
- `"CompactionDone"`: `data.context_size_before`,
`data.context_size_after`, and `data.context_window`.
- `"PlanReady"`: `data.path`, the absolute path of the plan file the
agent just wrote. Fires once per draft.
- `"SessionFocusChanged"`: `data.previous_session_id`, absent on the
first focus at startup.
- `"SessionStatusChanged"`: `data.status` (`"working"`, `"needs_input"`,
or `"idle"`), `data.title`, and `data.focused` (boolean).
- `"TaskStatusChanged"`: `data.id`, `data.name`, and `data.status`
(`"working"`, `"done"`, or `"error"`), when a subagent starts or
changes status. A task that comes back from disk already finished
stays quiet, so reloading a session does not replay old tasks.
- `"TaskFocusChanged"`: `data.id`, the task now on screen (`"main"` or a
subagent's id, what `ctx:task_id()` reports inside a tool). Fires for
the chat cycling keys, `maki.task.focus`, and a session switch that
lands on another task.
- `"ModelChanged"`: `data.model` in the shape `maki.model.get` returns,
plus `data.previous_spec`. Picking the model already in use stays
quiet, and so does startup.
`"TurnEnd"` fires once per turn and only for the main session, so
subagent turns never show up. A manual `/compact` ends its run without
ending a turn, so it stays quiet too.
Drivers are not all caught up. `"TurnStart"` and `"PlanReady"` come from
`maki-ui` only. `maki-acp` runs the agent on its own loop and does not
call the dispatcher yet, so plugins loaded under ACP receive no turn
events. Everything else fires under `maki -p` and sdk mode as well.
`"SessionEnd"` is the teardown signal: it fires first so handlers can
still inspect or stop the session's jobs, then session-owned jobs are
reaped. `data.reason` names the path it came from: `"reset"` (TUI
`/new`), `"load"`, `"delete"` (tab closed), `"shutdown"`, `"reload"`
(`/reload` is rebuilding the plugin host, and the session carries on in
the new one), `"replaced"` (an ACP client took the session's place), or
`"completed"` (a headless run finished).
On `"shutdown"`, `"reload"`, `"replaced"`, and `"completed"` the host is
already tearing down, so the UI is detached (`maki.fn` roundtrips fail
right away) and every handler shares one grace period. `data.deadline_ms`
is how much of it is left at dispatch, so write state out with `maki.fs`
and do not park. On the other reasons nothing waits and `data.deadline_ms`
is nil.
`"SessionReset"` stays TUI-only (`/new`) and fires on the same path as
`"SessionEnd"` with `reason = "reset"`.
Jobs started inside a callback die with the dispatch unless you await
them there (`jobwait`) or hand them to a session
(`scope = { session = ... }`).
**Parameters:**
- `{event}` (`string|string[]`) Event name or list of names.
- `{opts}` (`table`) Options:
- `callback` (`function`) called with an ev table `{ id, event, match, data }`.
- `once` (`boolean`) remove the handler after it fires once (default false).
- `pattern` (`string|string[]`) only fire when the pattern matches. `"*"` matches everything. Omit to match all.
**Returns:** (`integer`) Autocmd id.
**Example:**
```lua
local id = maki.api.create_autocmd("TurnEnd", {
callback = function(ev)
print("turn ended: " .. ev.event)
end,
})
```
---
### `maki.api.del_autocmd()` {#maki-api-del_autocmd}
```lua
maki.api.del_autocmd({id})
```
Remove a previously registered autocmd. Does nothing if the {id}
does not exist.
**Parameters:**
- `{id}` (`integer`) Id returned by `create_autocmd`.
**Example:**
```lua
maki.api.del_autocmd(id)
```
---
### `maki.api.exec_autocmds()` {#maki-api-exec_autocmds}
```lua
maki.api.exec_autocmds({event}, {opts?})
```
Fire one or more events manually. Every matching autocmd callback
runs to completion before this function returns.
A handler may suspend, so this call may too.
**Parameters:**
- `{event}` (`string|string[]`) Event name or list of names to fire.
- `{opts?}` (`table?`) Options:
- `pattern` (`string`) passed to callbacks as `ev.match`.
- `data` (`any`) arbitrary value passed as `ev.data`.
**Example:**
```lua
maki.api.exec_autocmds("MyEvent", {
pattern = "init",
data = { msg = "hello" },
})
```
---
### `maki.api.declare_slot()` {#maki-api-declare_slot}
```lua
maki.api.declare_slot({name}, {default})
```
Create a named extension point owned by your plugin. You provide a
{default} function, and other plugins can wrap it with layers using
`set_slot`. The returned callable runs the full chain: outermost
layer first, then inward, ending at {default}.
Throws if another plugin already owns a slot with the same {name}, or
if {name} starts with `"tool."`, which the host fires itself.
The chain is async: the default and every layer may park (`maki.fs.*`,
`maki.fn.jobwait`, `maki.agent.call_tool`, ...), and so does the
returned callable. Call it from a tool handler, a command, or an
autocmd, rather than from a `header` or `restore` function, which
cannot wait. The chain runs in your task, so cancelling the caller
cancels the layers it is waiting on.
**Parameters:**
- `{name}` (`string`) Unique slot name, e.g. `"myplugin.render"`.
- `{default}` (`function`) Default implementation, called when no layers wrap it.
**Returns:** (`function`) Callable that dispatches through all layers.
**Example:**
```lua
local render = maki.api.declare_slot("myplugin.render", function(text)
return text:upper()
end)
print(render("hello")) -- HELLO
```
---
### `maki.api.set_slot()` {#maki-api-set_slot}
```lua
maki.api.set_slot({name}, {wrapper})
```
Add a layer around an existing (or future) slot. Layers wrap the
default from the outside in. Each layer receives `prev` as its
first argument. Call `prev(...)` to continue down the chain.
Calling `prev` more than once throws.
You can call this before the owner runs `declare_slot`. The layer
is queued and attached when the slot is declared.
A layer may park, and one that throws is skipped: the chain continues
as if it had returned `prev(...)` untouched, so a broken layer never
takes the seam down with it.
Layers wrap in registration order, so the last one registered runs
first and sees the value before the others do.
Maki fires two slots per tool itself: `tool..input` before
permissions look at the call, and `tool..output` on the text it
produced. Both take `function(prev, value, ctx)` and answer with a
table to replace the value, nothing to leave it alone, or
`nil, reason` to stop the call. Wrapping one costs the capability the
tool declares, and a tool declaring none costs every permission. See
[Hooks](/docs/hooks/).
**Parameters:**
- `{name}` (`string`) Slot name to wrap.
- `{wrapper}` (`function`) Layer: `function(prev, ...)`. Call `prev(...)` to continue.
**Example:**
```lua
maki.api.set_slot("myplugin.render", function(prev, text)
return prev("[" .. text .. "]")
end)
```
---
### `maki.api.get_slots()` {#maki-api-get_slots}
```lua
maki.api.get_slots()
```
List all known slots and their current state. Useful for debugging
which plugins own or wrap each slot.
**Returns:** (`table`) Map of slot name to `{ owner, declared, fillers }`.
**Example:**
```lua
for name, info in pairs(maki.api.get_slots()) do
print(name, info.owner, info.declared)
end
```
## maki.agent {#maki-agent}
Subagent primitives for plugins that need to talk to an LLM.
This module gives you the building blocks: resolve which model to use,
build a system prompt, list available tools, call a tool directly, or
open a full session with its own conversation history.
Policy like retries, validation, and concurrency lives in the calling
plugin, not here.
```lua
local tools = maki.agent.tools(ctx, { audience = "general_sub" })
local sess = maki.agent.session(ctx, {
system = "You are a helpful assistant.",
tools = tools,
})
local r = sess:prompt("Hello!")
print(r.text)
sess:close()
```
---
### `maki.agent.resolve_model()` {#maki-agent-resolve_model}
```lua
maki.agent.resolve_model({ctx}, {opts?})
```
Look up the model that the current agent is using, or pick a cheaper one.
You might want a cheaper model for simple subtasks (summaries, classification)
without hard-coding a model name.
The returned table has fields: `id` (string), `tier` (string),
`provider` (string), `spec` (string).
**Parameters:**
- `{ctx}` (`LuaCtx`) Agent context.
- `{opts?}` (`table?`) Optional fields:
- `tier` (`string?`) target tier, e.g. `"fast"`, `"mid"`, `"best"`. Clamped to
the parent tier so you cannot escalate.
- `spec` (`string?`) exact model spec string, e.g. `"claude-3-5-haiku-20241022"`.
Takes precedence over `tier`.
**Returns:** (`table?`, `string?`) Model table on success, or `(nil, err)` on failure.
**Example:**
```lua
local model, err = maki.agent.resolve_model(ctx, { tier = "fast" })
if err then error(err) end
print(model.spec, model.tier)
```
---
### `maki.agent.system_prompt()` {#maki-agent-system_prompt}
```lua
maki.agent.system_prompt({ctx}, {opts})
```
Build a system prompt from a built-in template. Environment variables like
`{cwd}` are substituted automatically. Use this when you need a ready-made
prompt for a subagent session.
**Parameters:**
- `{ctx}` (`LuaCtx`) Agent context.
- `{opts}` (`table`) Required fields:
- `prompt_id` (`string`) one of `"research"`, `"general"`, `"system"`.
Optional fields:
- `instructions` (`string|boolean?`) extra text appended to the prompt.
`true` loads instructions from the project `.maki/instructions` file.
`false` or nil omits them.
**Returns:** (`string?`, `string?`) The assembled prompt string, or `(nil, err)` on failure.
**Example:**
```lua
local prompt, err = maki.agent.system_prompt(ctx, {
prompt_id = "research",
instructions = true,
})
if err then error(err) end
```
---
### `maki.agent.tools()` {#maki-agent-tools}
```lua
maki.agent.tools({ctx}, {opts})
```
Get the list of tool definitions for a given audience. Pass the result
straight into `maki.agent.session()` or use it to inspect what tools are
available.
**Parameters:**
- `{ctx}` (`LuaCtx`) Agent context.
- `{opts}` (`table`) Required fields:
- `audience` (`string`) tool audience filter, e.g. `"general"`, `"subagent"`,
`"general_sub"`.
Optional fields:
- `only` (`string[]?`) include only these tool names.
- `except` (`string[]?`) exclude these tool names.
- `workflow` (`boolean?`) use workflow-mode descriptions. Default: `false`.
- `spec` (`string?`) evaluate capability exclusions against this model spec.
- `mcp` (`boolean?`) describe tools as if MCP is reachable. Default: `true`.
Pass what you pass to `maki.agent.session()`. Otherwise the descriptions
advertise MCP tools that the session has no way to call.
**Returns:** (`table?`, `string?`) Array of tool definition tables, or `(nil, err)` on failure.
**Example:**
```lua
local defs, err = maki.agent.tools(ctx, {
audience = "general_sub",
except = { "bash", "write" },
})
if err then error(err) end
print(#defs .. " tools available")
```
---
### `maki.agent.callable_tools()` {#maki-agent-callable_tools}
```lua
maki.agent.callable_tools({ctx})
```
Every tool name this context can dispatch: registry tools, MCP tools
(deferred ones included), host tools (ACP client tools, a subagent's
`structured_output`) and `tool_search`. Reach for it when you expose tools
inside a sandbox and need the names to bind. `maki.api.get_tools()` covers
the registry alone and has no view of the session.
The list already accounts for this session's audience, the config's
`disabled_tools` and the model's capabilities. Read `audiences` to layer
your own policy on top. A sandbox wants `interpreter`.
Each name shows up once, described by the tool a call would really reach, so
a host tool that shadows a registry name reports its own audience rather
than the shadowed one's.
**Parameters:**
- `{ctx}` (`LuaCtx`) Agent context.
**Returns:** (`table?`, `string?`) Array of `{ name, alias?, source, audiences, schema? }`,
or `(nil, err)` on failure. `source` is one of `"native"`, `"local"`,
`"mcp"`. `alias` is a safe identifier to bind, set only when `name` is not
one (say `srv__get-docs`). Dispatch `name` in every case. `schema` comes
with registry tools only.
**Example:**
```lua
local tools, err = maki.agent.callable_tools(ctx)
if err then error(err) end
for _, t in ipairs(tools) do
print(t.source, t.alias or t.name)
end
```
---
### `maki.agent.call_tool()` {#maki-agent-call_tool}
```lua
maki.agent.call_tool({ctx}, {name}, {input}, {opts?})
```
Run a tool by name and wait for the result. This is how you call built-in
tools (like `read`, `bash`, `glob`) from Lua without going through the LLM.
Live events (streaming output, annotations, cumulative usage) are delivered
through optional callbacks while the tool runs.
**Parameters:**
- `{ctx}` (`LuaCtx`) Agent context.
- `{name}` (`string`) Tool name, e.g. `"bash"`, `"read"`.
- `{input}` (`table|any`) Tool input (JSON-serializable). Must match the tool's `input_schema`.
- `{opts?}` (`table?`) Optional fields:
- `timeout` (`integer?`) deadline in seconds.
- `on_live_buf` (`function?`) called with a `BufHandle` for each live buffer
the tool publishes. Must not yield.
- `on_annotation` (`function?`) called with an annotation string for each
annotation event. Must not yield.
- `on_usage` (`function?`) called with a formatted cumulative token usage
string. Must not yield.
**Returns:** (`string?`, `string?`) Tool output text, or `(nil, err)` on failure.
**Example:**
```lua
local out, err = maki.agent.call_tool(ctx, "bash", {
command = "ls -la",
timeout = 10,
})
if err then error(err) end
print(out)
```
---
### `maki.agent.session()` {#maki-agent-session}
```lua
maki.agent.session({ctx}, {opts})
```
Create a new subagent session. The session inherits the parent model and
MCP handle unless you override them. You get back a `Session` object that
you can send messages to with `:prompt()`.
This is the main way to spin up a sub-conversation with its own history
and tool set.
**Parameters:**
- `{ctx}` (`LuaCtx`) Agent context.
- `{opts}` (`table`) Optional fields:
- `model_spec` (`string?`) model spec string to use instead of the parent model.
- `system` (`string?`) system prompt. Defaults to empty.
- `tools` (`table?`) tool definitions array (from `maki.agent.tools()`).
- `local_tools` (`table?`) map of `name -> spec` for Lua-backed tools. Each spec
requires `description` (string), `input_schema` (table), and
`handler` (function). The handler receives the input table and must return
`(string)` or `(nil, err)`. Optional `audiences` (string[]) gates who may
call it, the same way `maki.api.register_tool` does. The default is the
model alone, so a script cannot reach it through `code_execution`.
- `name` (`string?`) display name for logs and UI.
- `audience` (`string?`) tool audience for capability gating. Default: `"general_sub"`.
- `mcp` (`boolean?`) give the session access to MCP tools. Their
definitions are injected automatically each turn (deferred behind
`tool_search`), so don't put MCP definitions in `tools`. The session
starts with no loaded tools of its own. Default: `true`.
- `thinking` (`string|integer?`) thinking mode: `"off"`, `"adaptive"`, an
effort level (`"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`,
`"max"`), or a budget integer (token count). Inherits the parent
setting if omitted, and is capped at it otherwise.
- `fast` (`boolean?`) use fast mode. Inherits parent setting if omitted.
**Returns:** ([`Session?`](#maki-agent-Session), `string?`) Session handle, or `(nil, err)` on failure.
**Example:**
```lua
local tools = maki.agent.tools(ctx, { audience = "general_sub" })
local sess, err = maki.agent.session(ctx, {
system = "You are a research assistant.",
tools = tools,
name = "researcher",
})
if err then error(err) end
-- Close before handling the error, so no path leaves the session open.
local result, prompt_err = sess:prompt("Summarize this file.")
sess:close()
if prompt_err then error(prompt_err) end
```
## maki.agent.Session {#maki-agent-Session}
A subagent session with its own conversation history.
Create one with `maki.agent.session()`, then send messages with
`:prompt()`. The session remembers previous turns, so you can have
a multi-step conversation.
Always call `:close()` when you are done, on error paths too. The
garbage collector is a fallback that may never run while the VM sits
idle, so a session you only drop can stay open for the rest of the run.
---
### `Session:prompt()` {#Session-prompt}
```lua
Session:prompt({message})
```
Send a message to the subagent and wait for its full response. The agent
loop runs to completion, calling tools as needed. Conversation history is
kept across calls, so you can have a multi-turn conversation.
The returned table has fields: `text` (string), `duration_ms` (integer),
`input_tokens` (integer), `output_tokens` (integer). `text` is an empty
string when the subagent produced no text block (e.g. it only called
tools).
**Parameters:**
- `{message}` (`string`) User message to send.
**Returns:** (`table?`, `string?`) Result table on success, or `(nil, err)` on
failure. A run cut short after streaming some text hands you both: the
error and a `{ text = }` table.
**Example:**
```lua
local r, err = sess:prompt("What files are in this project?")
if err then error(err) end
print(r.text)
print(r.input_tokens .. " input, " .. r.output_tokens .. " output tokens")
```
---
### `Session:close()` {#Session-close}
```lua
Session:close()
```
Close the session and flush its history back to the parent agent. Calling
it more than once is safe.
Close on every path, error paths included. Dropping the session instead
leaves the work to the Lua garbage collector, which may never run while
the VM sits idle, and the subagent's event relay stays alive until it does.
## maki.async {#maki-async}
Tools for running things concurrently in Lua plugins.
Use `run` to fire off background tasks, `gather` or `join` to run
several functions at once, and `semaphore` to limit concurrency.
The `await` and `wrap` helpers bridge callback-based APIs into
coroutine-friendly calls.
```lua
local results = maki.async.gather({
function() return fetch("a.txt") end,
function() return fetch("b.txt") end,
})
```
---
### `maki.async.run()` {#maki-async-run}
```lua
maki.async.run({fn}, {on_finish?})
```
Fire off a function as a new async task. It runs in the background and
you do not wait for it. If you need the result, pass an {on_finish}
callback.
**Parameters:**
- `{fn}` (`function`) Zero-argument function to execute.
- `{on_finish?}` (`function?`) Optional callback `function(err, result)`. Called once {fn} completes.
**Example:**
```lua
maki.async.run(function()
local data = expensive_fetch()
process(data)
end)
```
---
### `maki.async.sleep()` {#maki-async-sleep}
```lua
maki.async.sleep({ms})
```
Suspend the calling task for {ms} milliseconds. The plugin thread is
never blocked, so other tasks and the UI keep running, and a cancel
still lands while you sleep.
For a timer that has to outlive the tool call that started it, such
as a toast dismissing itself, use `maki.defer_fn`.
**Parameters:**
- `{ms}` (`integer`) Milliseconds to sleep.
**Example:**
```lua
maki.async.run(function()
maki.async.sleep(4000)
win:close()
end)
```
---
### `maki.async.await()` {#maki-async-await}
```lua
maki.async.await({argc}, {fn}, {...})
```
Turn a callback-based function into a normal call you can use in a coroutine. It calls `fn(..., callback)`, inserting the callback at position {argc}, then suspends your coroutine until the callback fires. You get back whatever the callback was called with.
**Parameters:**
- `{argc}` (`integer`) Total number of positional arguments {fn} expects (including the callback). Must be >= 1.
- `{fn}` (`function`) Callback-based function to call.
- `{...}` (`any`) Extra arguments forwarded to {fn} before the injected callback.
**Returns:** (`...`) Values passed by the caller to the injected callback.
**Example:**
```lua
local result = maki.async.await(2, http.get, url)
```
---
### `maki.async.wrap()` {#maki-async-wrap}
```lua
maki.async.wrap({argc}, {fn})
```
Create a coroutine-friendly wrapper around a callback-based function. The wrapper calls `maki.async.await` for you, so you can use the result like a normal function call.
**Parameters:**
- `{argc}` (`integer`) Callback position, forwarded to `maki.async.await`.
- `{fn}` (`function`) Callback-based function to wrap.
**Returns:** (`function`) Wrapped function you can call like a normal function.
**Example:**
```lua
local get = maki.async.wrap(2, http.get)
local body = get(url)
```
---
### `maki.async.join()` {#maki-async-join}
```lua
maki.async.join({max_jobs}, {fns})
```
Run all functions in {fns} with at most {max_jobs} going at once. Waits until every function has finished. Unlike `gather`, this does not return individual results.
**Parameters:**
- `{max_jobs}` (`integer`) Maximum number of functions running at the same time.
- `{fns}` (`table`) Array of zero-argument functions to execute.
**Example:**
```lua
maki.async.join(4, {
function() process(files[1]) end,
function() process(files[2]) end,
function() process(files[3]) end,
})
```
---
### `maki.async.gather()` {#maki-async-gather}
```lua
maki.async.gather({fns})
```
Run all functions in {fns} at the same time and collect their results.
Unlike `join`, this gives you back the return value (or error) from each
function. The results are in the same order as the input.
Each entry in the result array has `ok` (boolean), and either `value`
(on success) or `err` (string, on failure).
**Parameters:**
- `{fns}` (`table`) Array of zero-argument functions.
**Returns:** (`table`) Array of result tables, one per function.
**Example:**
```lua
local results = maki.async.gather({
function() return fetch("a.txt") end,
function() return fetch("b.txt") end,
})
for i, r in ipairs(results) do
if r.ok then print(r.value) else print("error: " .. r.err) end
end
```
---
### `maki.async.semaphore()` {#maki-async-semaphore}
```lua
maki.async.semaphore({n})
```
Create a counting semaphore that allows at most {n} concurrent permits.
Use this to limit how many tasks hit a resource at the same time.
**Parameters:**
- `{n}` (`integer`) Maximum number of concurrent permits. Values below 1 are clamped to 1.
**Returns:** ([`maki.async.Semaphore`](#maki-async-Semaphore)) A new semaphore.
**Example:**
```lua
local sem = maki.async.semaphore(5)
-- each task acquires a permit before doing work
local permit = sem:acquire()
do_work()
permit:release()
```
---
### `maki.async.on_cancel()` {#maki-async-on_cancel}
```lua
maki.async.on_cancel({fn})
```
Register {fn} to run as soon as the current task is cancelled or hits
its deadline, without waiting for whatever it is doing to finish. Use
it to paint the cancelled state: a handler waiting on children
(`gather`, `call_tool`) stays parked until they wind down, so anything
after the wait is too late to reach the screen.
The callback receives the reason (`"cancelled"` or `"timeout"`) and may
still call `ctx:finish`; the host prefers that reply over the generic
cancelled/timeout error. Mark it `is_error = true` and end it with a
marker, so the model knows the output it gets is cut short.
The callback runs outside your coroutine, so it must not yield. It
fires at most once, immediately if the task is already cancelled. An
error inside it is logged and never reaches your handler, and the
other hooks still run.
**Parameters:**
- `{fn}` (`function`) Function to run on cancel; receives the reason string.
**Example:**
```lua
maki.async.on_cancel(function(reason)
view:append({ { reason, "tool_error" } })
ctx:finish({ llm_output = partial .. "\n[cancelled; output is partial]", is_error = true })
end)
maki.async.gather(children)
```
## maki.async.Semaphore {#maki-async-Semaphore}
A counting semaphore for limiting how many tasks run at once.
Create one with `maki.async.semaphore(n)`, then call `:acquire()` to
get a permit before doing work. If the task is cancelled, the acquire
is cancelled too.
---
### `Semaphore:acquire()` {#Semaphore-acquire}
```lua
Semaphore:acquire()
```
Wait for a permit from the semaphore. Your coroutine suspends until a slot
opens up. If the owning task is cancelled, the acquire is cancelled too.
**Returns:** ([`maki.async.Permit`](#maki-async-Permit)) A permit handle. Call `:release()` when done, or let it be garbage collected.
**Example:**
```lua
local sem = maki.async.semaphore(3)
local permit = sem:acquire()
-- do work that needs the slot
permit:release()
```
## maki.async.Permit {#maki-async-Permit}
One slot in a semaphore, obtained from `Semaphore:acquire()`.
The slot is held until you call `:release()` or until the permit is
garbage collected. Releasing early lets other tasks acquire sooner.
---
### `Permit:release()` {#Permit-release}
```lua
Permit:release()
```
Give the permit back to the semaphore so another task can acquire it.
Throws if you already released this permit.
## maki.base64 {#maki-base64}
Base64 encoding and decoding, modelled after `vim.base64`.
Both functions accept strings and Luau buffers, so you can round-trip
binary data read with `maki.fs.read_bytes`.
```lua
local encoded = maki.base64.encode("hello")
local decoded = maki.base64.decode(encoded)
```
---
### `maki.base64.encode()` {#maki-base64-encode}
```lua
maki.base64.encode({data})
```
Encode {data} to standard Base64. Like `vim.base64.encode`.
Accepts both strings and Luau buffers.
**Parameters:**
- `{data}` (`string|buffer`) Data to encode.
**Returns:** (`string`) Base64-encoded string.
**Example:**
```lua
maki.base64.encode("hello") -- "aGVsbG8="
```
---
### `maki.base64.decode()` {#maki-base64-decode}
```lua
maki.base64.decode({str})
```
Decode a Base64-encoded {str} back to its original bytes. Like `vim.base64.decode`.
Throws if {str} is not valid Base64.
**Parameters:**
- `{str}` (`string|buffer`) Base64-encoded text.
**Returns:** (`string`) Decoded bytes as a string.
**Example:**
```lua
maki.base64.decode("aGVsbG8=") -- "hello"
```
## maki.env {#maki-env}
Paths to maki's own directories (config, state, logs, legacy).
Use these to locate config files or persistent state without hard-coding paths.
These answer where maki keeps its files, so they need `fs_read`, which a
plugin needs to read anything there anyway. Asking for a path must not
cost a plugin `env`, which covers the process environment alone
(`maki.uv.os_getenv`), where secrets live.
```lua
local cfg = maki.env.config_dir()
```
---
### `maki.env.state_dir()` {#maki-env-state_dir}
```lua
maki.env.state_dir()
```
Return the directory where maki stores runtime state (sessions, auth tokens, etc.).
Typically something like `~/.local/state/maki`.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Returns:** (`string?`) State directory path, or nil if it cannot be determined.
**Example:**
```lua
local dir = maki.env.state_dir()
```
---
### `maki.env.config_dir()` {#maki-env-config_dir}
```lua
maki.env.config_dir()
```
Return the directory where maki looks for user configuration files.
Typically something like `~/.config/maki`.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Returns:** (`string?`) Config directory path, or nil if it cannot be determined.
**Example:**
```lua
local dir = maki.env.config_dir()
```
---
### `maki.env.logs_dir()` {#maki-env-logs_dir}
```lua
maki.env.logs_dir()
```
Return the directory where maki writes its log files (`maki.log`).
Typically something like `~/.local/logs/maki`.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Returns:** (`string?`) Logs directory path, or nil if it cannot be determined.
**Example:**
```lua
local dir = maki.env.logs_dir()
```
---
### `maki.env.legacy_dir()` {#maki-env-legacy_dir}
```lua
maki.env.legacy_dir()
```
Return the legacy config path (`~/.maki`), if it exists on disk.
Useful for migration logic. Returns nil when there is no legacy directory.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Returns:** (`string?`) Legacy directory path, or nil if not present.
## maki.fn {#maki-fn}
Process and environment helpers, modeled after Neovim's `vim.fn` job
control. Use these to run shell commands, wait for output, and check
whether programs are installed.
```lua
local id = maki.fn.jobstart("git status", {
on_exit = function(_, code) print("done: " .. code) end,
})
```
---
### `maki.fn.jobstart()` {#maki-fn-jobstart}
```lua
maki.fn.jobstart({cmd}, {opts?})
```
Run a command in the background. A string runs through `bash -c` on Unix
or `cmd /C` on Windows; a table is spawned as argv, with no shell in
between (nothing in it can be read as a redirect, a pipe, or `$(...)`).
You get back a job id that you can pass to `jobstop` or `jobwait` to
control the process.
`stdout` and `stderr` route a stream to a file instead of into maki. A
path is opened for append and handed to the child, so nothing is buffered
here: no callback, no tail, no events for that stream, and it counts as
truncated everywhere a tail is reported. That makes the two mutually
exclusive with `on_stdout` / `on_stderr` for the same stream, and a path
additionally needs the `fs_write` permission. To both persist and react,
run one job writing the file and a second one tailing it.
Requires the `run` [plugin permission](#plugin-permissions).
**Parameters:**
- `{cmd}` (`string|table`) Shell command, or an argv table like
`{ "tail", "-F", path }`.
- `{opts?}` (`table?`) Optional settings:
- `cwd` (`string?`) working directory (tilde is expanded).
- `env` (`table?`) extra environment variables, `{ VAR = "value" }`.
- `on_stdout` (`function?`) called with `(job_id, line)` for each stdout line.
- `on_stderr` (`function?`) called with `(job_id, line)` for each stderr line.
- `on_exit` (`function?`) called with `(job_id, code)` when the process finishes.
- `stdout` (`string|false?`) append stdout to this path, or `false` to
discard it.
- `stderr` (`string|false?`) same for stderr; both may name one path.
- `scope` (`string|table?`) job lifetime. `"task"` (default) ends the job
with the current call. `"plugin"` keeps it alive until the plugin
unloads or reloads. `{ session = "" }` keeps it alive until that
session ends, and survives plugin reload.
- `tail` (`integer?`) trailing lines per stream kept for `jobinfo`
(default 20, 0 disables, max 1024).
- `name` (`string?`) handle for `jobfind`, unique among the live jobs this
plugin can see. Starting a second job under a live name is an error.
**Returns:** (`integer`) Job id.
**Example:**
```lua
local id = maki.fn.jobstart({ "rg", "--json", pattern, dir }, {
on_stdout = function(_, line) print(line) end,
on_exit = function(_, code) print("exit: " .. code) end,
})
```
---
### `maki.fn.jobstop()` {#maki-fn-jobstop}
```lua
maki.fn.jobstop({job_id})
```
Kill a running job immediately (SIGKILL on Unix). Safe to call on
jobs that already exited or on unknown ids.
Requires the `run` [plugin permission](#plugin-permissions).
**Parameters:**
- `{job_id}` (`integer`) Job id returned by `jobstart`.
**Example:**
```lua
maki.fn.jobstop(id)
```
---
### `maki.fn.jobforget()` {#maki-fn-jobforget}
```lua
maki.fn.jobforget({job_id})
```
Drop an exited session-owned job from the store. Running jobs are left
alone; use `jobstop` to kill those. Unknown ids are a no-op.
Requires the `run` [plugin permission](#plugin-permissions).
**Parameters:**
- `{job_id}` (`integer`) Job id returned by `jobstart`.
**Example:**
```lua
maki.fn.jobforget(id)
```
---
### `maki.fn.jobwait()` {#maki-fn-jobwait}
```lua
maki.fn.jobwait({job_id}, {timeout_ms?})
```
Wait for a job to finish and collect its output. Returns a result
table with `stdout`, `stderr`, `exit_code`, and `truncated`. A job that
already exited answers from its captured tail, so `truncated` says
whether that tail ever lost a line (`tail` too small or 0, or the stream
redirected away). Waiting on a live job collects every line and is never
truncated. Returns `nil` if the job does not finish before the timeout.
While waiting, the job's `on_stdout`, `on_stderr`, and `on_exit`
callbacks fire as events arrive (like Neovim), so you can stream
output into a buffer while parked here. An already-exited
session-owned job answers from its snapshot and fires no callbacks.
Task and plugin jobs leave the store on exit, so waiting after that
is an error.
Requires the `run` [plugin permission](#plugin-permissions).
**Parameters:**
- `{job_id}` (`integer`) Job id returned by `jobstart`.
- `{timeout_ms?}` (`integer?`) Maximum wait in milliseconds (default 30000).
**Returns:** (`table?`) `{ stdout, stderr, exit_code, truncated }`, or nil on timeout.
**Example:**
```lua
local id = maki.fn.jobstart("echo hello")
local result = maki.fn.jobwait(id, 5000)
if result then
print(result.stdout)
end
```
---
### `maki.fn.jobinfo()` {#maki-fn-jobinfo}
```lua
maki.fn.jobinfo({job_id})
```
Snapshot a job this plugin can see. Live jobs report tails collected
so far; session-owned jobs still answer after they exit.
Requires the `run` [plugin permission](#plugin-permissions).
**Parameters:**
- `{job_id}` (`integer`) Job id returned by `jobstart`.
**Returns:** (`table|nil`, `string|nil`) `{ id, command, name, pid, session, status,
exit_code, elapsed_secs, stdout_lines, stderr_lines }`, or nil and
an error. `status` is `"running"` or `"exited"`.
**Example:**
```lua
local info = maki.fn.jobinfo(id)
```
---
### `maki.fn.joblist()` {#maki-fn-joblist}
```lua
maki.fn.joblist({session?})
```
List jobs this plugin can see, including exited session-owned jobs (so an
id started before a reload stays findable). Rows identify the job; call
`jobinfo` for tails. Pass a session id to list only that session's jobs.
Plugin and task jobs carry no session, so a filter never matches them.
Requires the `run` [plugin permission](#plugin-permissions).
**Parameters:**
- `{session?}` (`string?`) Session id filter.
**Returns:** (`table`) array of `{ id, command, name, pid, session, status,
exit_code, elapsed_secs }`.
**Example:**
```lua
local jobs = maki.fn.joblist(maki.session.current())
```
---
### `maki.fn.jobattach()` {#maki-fn-jobattach}
```lua
maki.fn.jobattach({job_id}, {opts})
```
Attach (or replace) callbacks on a job this plugin can see. This is how a
plugin picks its jobs back up after a reload: unloading drops the Lua
callbacks of its session-owned jobs, but the processes keep running.
Keys absent from {opts} leave the current callback alone. Attaching
`on_exit` to a job that already exited still fires it once, with the
recorded exit code, so a reload racing the exit cannot lose it.
Requires the `run` [plugin permission](#plugin-permissions).
**Parameters:**
- `{job_id}` (`integer`) Job id, e.g. from `joblist`.
- `{opts}` (`table`) `on_stdout`, `on_stderr`, `on_exit`: a function, or `false` to clear.
**Returns:** (`boolean|nil`, `string|nil`) true on success, or nil and an error.
**Example:**
```lua
-- A monitor that survives /reload: adopt the live job or start one.
local sid = maki.session.current()
local id = maki.fn.jobfind("log-tail")
or maki.fn.jobstart({ "tail", "-F", path }, {
name = "log-tail",
scope = { session = sid },
})
maki.fn.jobattach(id, {
on_stdout = function(_, line) maki.session.notify(line, { session = sid }) end,
on_exit = function(_, code) maki.session.notify("tail died: " .. code, { session = sid }) end,
})
```
---
### `maki.fn.jobfind()` {#maki-fn-jobfind}
```lua
maki.fn.jobfind({name})
```
Find the live job of this plugin that `jobstart` registered under {name}.
An exited job never answers, so `jobfind(...) or jobstart(...)` restarts a
job that died instead of adopting its id. The name stays on the `joblist`
row, which is where you go to see why it died.
Requires the `run` [plugin permission](#plugin-permissions).
**Parameters:**
- `{name}` (`string`) Name passed to `jobstart`.
**Returns:** (`integer|nil`, `string|nil`) Job id, or nil and an error when no live
job holds the name.
**Example:**
```lua
local id = maki.fn.jobfind("log-tail")
if not id then
id = maki.fn.jobstart("tail -F /tmp/log", { name = "log-tail", scope = "plugin" })
end
```
---
### `maki.fn.executable()` {#maki-fn-executable}
```lua
maki.fn.executable({name})
```
Check whether {name} can be found on `$PATH` or is an absolute path
to a file. Returns 1 when found, 0 otherwise (matches Neovim's
`vim.fn.executable`).
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Parameters:**
- `{name}` (`string`) Program name (e.g. `"git"`) or absolute path.
**Returns:** (`integer`) `1` if found, `0` otherwise.
**Example:**
```lua
if maki.fn.executable("rg") == 1 then
-- use ripgrep
end
```
---
### `maki.fn.winsaveview()` {#maki-fn-winsaveview}
```lua
maki.fn.winsaveview()
```
Read the viewport of the focused chat transcript, like Neovim's
`vim.fn.winsaveview()`. The transcript is the only scrollable window
maki has, so there is no window argument.
`topline` is the 1-based transcript line at the top of the viewport, so
the last visible one is `math.min(topline + height - 1, line_count)`.
`auto_scroll` has no Vim counterpart: it is true while the transcript
follows streaming output.
**Returns:** (`table|nil`, `string|nil`) `{topline, line_count, height, auto_scroll}`, or nil and an error.
**Example:**
```lua
local view = maki.fn.winsaveview()
maki.fn.winrestview({ topline = view.topline + 1 })
```
---
### `maki.fn.winrestview()` {#maki-fn-winrestview}
```lua
maki.fn.winrestview({view})
```
Scroll the focused chat transcript so that the `topline` field of
{view} becomes the top visible line, like Neovim's
`vim.fn.winrestview()`. Out of range values are clamped. Other keys are
ignored, so a table straight from `winsaveview()` round-trips.
Scrolling away from the bottom unpins the transcript; landing back at
the bottom re-pins it so streaming output keeps following.
**Parameters:**
- `{view}` (`table`) View to restore. Only `topline` (1-based) is read.
**Returns:** (`boolean|nil`, `string|nil`) true on success, or nil and an error.
**Example:**
```lua
maki.fn.winrestview({ topline = 1 })
```
## maki.fs {#maki-fs}
File-system utilities, modelled after `vim.fs` and `vim.uv`.
Fallible operations return `(value, err)` pairs and never throw.
Paths support `~/` expansion. Relative paths resolve from the current working directory.
```lua
local text, err = maki.fs.read("init.lua")
if err then return end
```
---
### `maki.fs.read()` {#maki-fs-read}
```lua
maki.fs.read({path})
```
Read the entire file at {path} as a UTF-8 string.
If the file contains bytes that are not valid UTF-8, this function throws.
Use `read_bytes` for binary files.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Parameters:**
- `{path}` (`string`) Absolute or relative file path. `~/` is expanded to the home directory.
**Returns:** (`string?`, `string?`) File contents, or nil plus an error message.
**Example:**
```lua
local text, err = maki.fs.read("config.toml")
if err then
maki.log.warn("could not read config: " .. err)
return
end
```
---
### `maki.fs.read_bytes()` {#maki-fs-read_bytes}
```lua
maki.fs.read_bytes({path})
```
Read the entire file at {path} as raw bytes, returned as a Luau buffer.
Useful for binary files or when you need to pass the data to `maki.base64.encode`.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Parameters:**
- `{path}` (`string`) Absolute or relative file path. `~/` is expanded to the home directory.
**Returns:** (`buffer?`, `string?`) File bytes as a Luau buffer, or nil plus an error message.
**Example:**
```lua
local buf, err = maki.fs.read_bytes("image.png")
if err then return end
local encoded = maki.base64.encode(buf)
```
---
### `maki.fs.metadata()` {#maki-fs-metadata}
```lua
maki.fs.metadata({path})
```
Get metadata for the file or directory at {path}.
Returns a table with `size` (integer), `is_file` (boolean), `is_dir` (boolean),
and `mtime` (number, fractional seconds since the Unix epoch; absent when the
filesystem does not report a modification time).
If {path} does not exist, returns nil with no error.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Parameters:**
- `{path}` (`string`) Absolute or relative path.
**Returns:** (`table?`, `string?`) Metadata table, nil if missing, or nil plus an error message.
**Example:**
```lua
local meta = maki.fs.metadata("src/main.rs")
if meta and meta.is_file then
print("size: " .. meta.size)
end
```
---
### `maki.fs.dirname()` {#maki-fs-dirname}
```lua
maki.fs.dirname({path})
```
Return the parent directory of {path}. Like `vim.fs.dirname`.
**Parameters:**
- `{path}` (`string`) File path.
**Returns:** (`string?`) Parent directory, or nil if {path} has no parent.
**Example:**
```lua
maki.fs.dirname("/home/user/init.lua") -- "/home/user"
```
---
### `maki.fs.basename()` {#maki-fs-basename}
```lua
maki.fs.basename({path})
```
Return the final component (the file name) of {path}. Like `vim.fs.basename`.
**Parameters:**
- `{path}` (`string`) File path.
**Returns:** (`string?`) File name, or nil for paths like `/`.
**Example:**
```lua
maki.fs.basename("/home/user/init.lua") -- "init.lua"
```
---
### `maki.fs.joinpath()` {#maki-fs-joinpath}
```lua
maki.fs.joinpath({...})
```
Join one or more path segments into a single path. Like `vim.fs.joinpath`.
**Parameters:**
- `{...}` (`string`) One or more path segments to join.
**Returns:** (`string`) The joined path.
**Example:**
```lua
maki.fs.joinpath("src", "api", "fs.rs") -- "src/api/fs.rs"
```
---
### `maki.fs.normalize()` {#maki-fs-normalize}
```lua
maki.fs.normalize({path})
```
Clean up `.` and `..` segments and make {path} absolute. Like `vim.fs.normalize`.
This is purely string-based and does not touch the filesystem.
**Parameters:**
- `{path}` (`string`) Path to normalize. `~/` is expanded.
**Returns:** (`string`) Normalized absolute path.
**Example:**
```lua
maki.fs.normalize("src/../src/api") -- "/home/user/project/src/api"
```
---
### `maki.fs.abspath()` {#maki-fs-abspath}
```lua
maki.fs.abspath({path})
```
Make {path} absolute by prepending the current working directory when needed.
Unlike `normalize`, this does not resolve `.` or `..` segments.
**Parameters:**
- `{path}` (`string`) Relative or absolute path. `~/` is expanded.
**Returns:** (`string`) Absolute path.
**Example:**
```lua
maki.fs.abspath("src/main.rs") -- "/home/user/project/src/main.rs"
```
---
### `maki.fs.parents()` {#maki-fs-parents}
```lua
maki.fs.parents({path})
```
Return all ancestor directories of {path}, from the immediate parent up to the root.
Handy for walking up a directory tree.
**Parameters:**
- `{path}` (`string`) File or directory path.
**Returns:** (`string[]`) Array of ancestor directory paths.
**Example:**
```lua
local dirs = maki.fs.parents("/home/user/project/src")
-- { "/home/user/project", "/home/user", "/home", "/" }
```
---
### `maki.fs.root()` {#maki-fs-root}
```lua
maki.fs.root({source}, {marker})
```
Walk upward from {source} looking for a directory that contains one of the
{marker} files or directories. Like `vim.fs.root`. Useful for finding the
project root.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Parameters:**
- `{source}` (`string`) Starting file or directory path.
- `{marker}` (`string|string[]`) Marker filename(s) to look for, e.g. `".git"` or `{"package.json", ".git"}`.
**Returns:** (`string?`, `string?`) Root directory path, or nil when not found.
**Example:**
```lua
local root = maki.fs.root("src/main.rs", { ".git", "Cargo.toml" })
if root then print("project root: " .. root) end
```
---
### `maki.fs.relpath()` {#maki-fs-relpath}
```lua
maki.fs.relpath({base}, {target})
```
Compute a relative path from {base} to {target}.
**Parameters:**
- `{base}` (`string`) Base directory path.
- `{target}` (`string`) Target path.
**Returns:** (`string`) Relative path from {base} to {target}.
**Example:**
```lua
maki.fs.relpath("/home/user", "/home/user/project/src") -- "project/src"
```
---
### `maki.fs.ext()` {#maki-fs-ext}
```lua
maki.fs.ext({path})
```
Return the file extension of {path}, without the leading dot.
**Parameters:**
- `{path}` (`string`) File path.
**Returns:** (`string?`) Extension, or nil if the path has no extension.
**Example:**
```lua
maki.fs.ext("main.rs") -- "rs"
maki.fs.ext("Makefile") -- nil
```
---
### `maki.fs.dir()` {#maki-fs-dir}
```lua
maki.fs.dir({path}, {opts?})
```
List the contents of the directory at {path}.
Each entry is a two-element array `{name, type}` where type is one of
`"file"`, `"directory"`, `"link"`, or `"unknown"`. Follows symlinks.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Parameters:**
- `{path}` (`string`) Directory path.
- `{opts?}` (`table?`) `depth` (integer, default 1): how many levels deep to recurse.
**Returns:** (`table?`, `string?`) Array of `{name, type}` entries, or nil plus an error message.
**Example:**
```lua
local entries, err = maki.fs.dir("src", { depth = 2 })
if err then return end
for _, e in ipairs(entries) do
print(e[1], e[2]) -- "main.rs" "file"
end
```
---
### `maki.fs.write()` {#maki-fs-write}
```lua
maki.fs.write({path}, {content})
```
Write {content} to the file at {path}, creating it if it does not exist
or overwriting it if it does.
Requires the `fs_write` [plugin permission](#plugin-permissions).
**Parameters:**
- `{path}` (`string`) Destination file path. `~/` is expanded.
- `{content}` (`string`) Text to write.
**Returns:** (`true?`, `string?`) `true` on success, or nil plus an error message.
**Example:**
```lua
local ok, err = maki.fs.write("out.txt", "hello world")
if err then print("write failed: " .. err) end
```
---
### `maki.fs.append()` {#maki-fs-append}
```lua
maki.fs.append({path}, {content})
```
Append {content} to the file at {path}, creating it (but not its parent
directory) if it does not exist.
Requires the `fs_write` [plugin permission](#plugin-permissions).
**Parameters:**
- `{path}` (`string`) Destination file path. `~/` is expanded.
- `{content}` (`string`) Text to append.
**Returns:** (`true?`, `string?`) `true` on success, or nil plus an error message.
**Example:**
```lua
local ok, err = maki.fs.append("out.log", "line\n")
if err then print("append failed: " .. err) end
```
---
### `maki.fs.atomic_write()` {#maki-fs-atomic_write}
```lua
maki.fs.atomic_write({path}, {content})
```
Atomically replace {path} with {content}. The parent directory must exist.
Readers observe either the old file or the complete new file.
Existing file permissions are preserved. On Unix, new files use mode 0600.
Requires the `fs_write` [plugin permission](#plugin-permissions).
**Parameters:**
- `{path}` (`string`) Destination file path. `~/` is expanded.
- `{content}` (`string`) Text to write.
**Returns:** (`true?`, `string?`) `true` on success, or nil plus an error message.
**Example:**
```lua
local ok, err = maki.fs.atomic_write("state.json", encoded)
if err then print("atomic write failed: " .. err) end
```
---
### `maki.fs.rm()` {#maki-fs-rm}
```lua
maki.fs.rm({path}, {opts?})
```
Delete the file, symlink, or directory at {path}.
Pass `recursive = true` to remove a non-empty directory tree (like `rm -r`).
Unlike `vim.fs.rm`, this also removes an empty directory without `recursive`.
Symlinks are removed themselves, never followed.
Requires the `fs_write` [plugin permission](#plugin-permissions).
**Parameters:**
- `{path}` (`string`) Path to the file or directory to remove.
- `{opts?}` (`table?`) `recursive` (boolean, default false): remove a directory and its contents recursively. `force` (boolean, default false): silently ignore a missing path.
**Returns:** (`true?`, `string?`) `true` on success, or nil plus an error message.
**Example:**
```lua
local ok, err = maki.fs.rm("temp.txt")
if err then print("rm failed: " .. err) end
maki.fs.rm("stale_dir", { recursive = true, force = true })
```
---
### `maki.fs.mkdir()` {#maki-fs-mkdir}
```lua
maki.fs.mkdir({path}, {opts?})
```
Create the directory at {path}. Set `parents = true` to create
intermediate directories, like `mkdir -p`.
Requires the `fs_write` [plugin permission](#plugin-permissions).
**Parameters:**
- `{path}` (`string`) Directory path to create.
- `{opts?}` (`table?`) `parents` (boolean, default false): create intermediate parent directories.
**Returns:** (`true?`, `string?`) `true` on success, or nil plus an error message.
**Example:**
```lua
maki.fs.mkdir("a/b/c", { parents = true })
```
---
### `maki.fs.glob()` {#maki-fs-glob}
```lua
maki.fs.glob({pattern}, {opts?})
```
Find files matching one or more glob patterns.
Respects `.gitignore` by default. Pass `sort = "mtime"` to get the most
recently modified files first.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Parameters:**
- `{pattern}` (`string|string[]`) Glob pattern or array of patterns.
- `{opts?}` (`table?`) `path` (string): search root. `limit` (integer): max results. `gitignore` (boolean, default true): respect .gitignore. `sort` (string): `"mtime"` sorts newest first.
**Returns:** (`string[]?`, `string?`) Array of absolute file paths, or nil plus an error message.
**Example:**
```lua
local files, err = maki.fs.glob("**/*.lua", { path = "plugins", limit = 10 })
if err then return end
for _, f in ipairs(files) do print(f) end
```
---
### `maki.fs.grep()` {#maki-fs-grep}
```lua
maki.fs.grep({pattern}, {opts?})
```
Search file contents for a regex {pattern}. Returns structured matches
grouped by file, similar to ripgrep output.
Each result entry has a `path` and a list of `groups`. Each group contains
`lines`, where every line has `line_nr`, `text`, and `is_match`.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Parameters:**
- `{pattern}` (`string`) Regular expression to search for.
- `{opts?}` (`table?`) `path` (string): search root. `include` (string): file glob filter (e.g. `"*.rs"`). `context_before` / `context_after` (integer): context lines around matches. `limit` (integer): max match groups. `max_line_bytes` (integer): skip lines longer than this.
**Returns:** (`table?`, `string?`) Array of `{path, groups}` tables, or nil plus an error message.
**Example:**
```lua
local hits, err = maki.fs.grep("TODO", { path = "src", include = "*.rs", limit = 5 })
if err then return end
for _, file in ipairs(hits) do
for _, g in ipairs(file.groups) do
for _, line in ipairs(g.lines) do
if line.is_match then print(file.path .. ":" .. line.line_nr) end
end
end
end
```
## maki.image {#maki-image}
Small building blocks for working with images: probe metadata, decode
pixels, resize, and encode back to bytes. Plugins compose these freely.
Decoding is guarded against pixel-bomb attacks (50 MP limit).
```lua
local img = maki.image.decode(raw_bytes)
local small = img:resize(1024, 768)
local png = small:encode("png")
```
---
### `maki.image.probe()` {#maki-image-probe}
```lua
maki.image.probe({data})
```
Read image metadata (format, dimensions) from raw bytes without fully
decoding the pixels. Much faster than `decode` when you only need to
check the size or format.
Returns a table with `format` (string), `width` (integer), `height`
(integer), or `(nil, err)` if the bytes are not a recognized image.
**Parameters:**
- `{data}` (`string|buffer`) Raw image bytes.
**Returns:** (`table?`, `string?`) Info table, or `(nil, err)` on failure.
**Example:**
```lua
local info, err = maki.image.probe(raw_bytes)
if err then error(err) end
print(info.format, info.width, info.height)
```
---
### `maki.image.decode()` {#maki-image-decode}
```lua
maki.image.decode({data})
```
Decode raw image bytes into an Image handle you can resize and re-encode.
Images larger than 50 megapixels are rejected to prevent memory bombs.
**Parameters:**
- `{data}` (`string|buffer`) Raw image bytes.
**Returns:** ([`maki.image.Image?`](#maki-image-Image), `string?`) Decoded image, or `(nil, err)` on failure.
**Example:**
```lua
local img, err = maki.image.decode(raw_bytes)
if err then error(err) end
print(img:width() .. "x" .. img:height())
```
## maki.image.Image {#maki-image-Image}
A decoded image you can inspect, resize, and re-encode.
Get one from `maki.image.decode()`. The image data lives in memory
until the handle is garbage collected.
---
### `Image:width()` {#Image-width}
```lua
Image:width()
```
Get the width of the image in pixels.
**Returns:** (`integer`) Width in pixels.
---
### `Image:height()` {#Image-height}
```lua
Image:height()
```
Get the height of the image in pixels.
**Returns:** (`integer`) Height in pixels.
---
### `Image:resize()` {#Image-resize}
```lua
Image:resize({max_w}, {max_h})
```
Shrink the image to fit inside {max_w} x {max_h}, keeping the aspect
ratio. If the image already fits, it is returned as-is. Never upscales.
**Parameters:**
- `{max_w}` (`integer`) Maximum width in pixels. Must be positive.
- `{max_h}` (`integer`) Maximum height in pixels. Must be positive.
**Returns:** ([`maki.image.Image`](#maki-image-Image)) A new image handle (or the same one if no resize was needed).
**Example:**
```lua
local img = maki.image.decode(raw_bytes)
local small = img:resize(800, 600)
local encoded = small:encode("jpeg")
```
---
### `Image:encode()` {#Image-encode}
```lua
Image:encode({format})
```
Encode the image into raw bytes in the given format. Use this to prepare
images for sending over the network or writing to disk.
**Parameters:**
- `{format}` (`string`) Output format: `"png"`, `"jpeg"`, or `"jpg"`.
**Returns:** (`string`) Encoded image bytes.
**Example:**
```lua
local bytes = img:encode("png")
-- bytes is a Lua string containing the raw PNG data
```
## maki.interpreter {#maki-interpreter}
Run Python code in a memory-safe, time-limited sandbox.
The sandbox uses the monty interpreter. Python code can call back into
Lua-defined tools, and stdout is streamed line by line.
```lua
local r, err = maki.interpreter.run("print('hello')", {
timeout = 10,
max_memory_mb = 128,
on_output = function(line) print(line) end,
})
```
---
### `maki.interpreter.run()` {#maki-interpreter-run}
```lua
maki.interpreter.run({code}, {opts})
```
Run Python code in a sandboxed interpreter with memory and time limits.
Stdout lines are streamed to your {on_output} callback as they are produced.
If the Python code calls tools, those calls are dispatched to the Lua
functions you provide in {opts}.tools.
The result table has optional fields: `stdout` (string, trimmed combined
output) and `output` (string, the final expression value). On error, the
table is empty and the second return value is the error message.
Requires the `run` [plugin permission](#plugin-permissions).
**Parameters:**
- `{code}` (`string`) Python source code to execute.
- `{opts}` (`table`) Required fields:
- `timeout` (`integer`) execution time limit in seconds.
- `max_memory_mb` (`integer`) memory limit in megabytes.
- `on_output` (`function`) called with each stdout line (string) as it is
produced. Must not yield.
Optional fields:
- `preamble` (`string?`) Python source (imports, helpers) compiled ahead of
{code}. Tracebacks are rebased so line 1 is {code} line 1.
- `tools` (`table?`) map of `name -> function` for tools the sandbox may call.
Each function receives the tool input table and must return `(string)` or
`(nil, err)`. Tool calls are batched and dispatched concurrently.
**Returns:** (`table`, `string?`) Result table, plus an error string on failure.
**Example:**
```lua
local result, err = maki.interpreter.run("print(2 + 2)", {
timeout = 30,
max_memory_mb = 256,
on_output = function(line) print("py: " .. line) end,
})
if err then error(err) end
if result.stdout then print(result.stdout) end
```
## maki.json {#maki-json}
JSON encoding, decoding, and schema validation. Encode Lua
tables to JSON strings, decode JSON back into tables, and
optionally validate data against a JSON Schema.
```lua
local s = maki.json.encode({ ok = true })
local t = maki.json.decode(s)
```
---
### `maki.json.encode()` {#maki-json-encode}
```lua
maki.json.encode({value})
```
Turn a Lua value into a JSON string. Tables, strings, numbers,
booleans, and nil all work. Functions and userdata cannot be
serialized.
**Parameters:**
- `{value}` (`any`) Lua value to encode.
**Returns:** (`string?`, `string?`) JSON string, or nil plus an error.
**Example:**
```lua
local s, err = maki.json.encode({ name = "maki", version = 1 })
print(s) -- {"name":"maki","version":1}
```
---
### `maki.json.decode()` {#maki-json-decode}
```lua
maki.json.decode({str})
```
Parse a JSON string into a Lua value. Objects become tables and
arrays become 1-indexed sequences.
**Parameters:**
- `{str}` (`string`) JSON string to decode.
**Returns:** (`any?`, `string?`) Decoded value, or nil plus an error.
**Example:**
```lua
local t, err = maki.json.decode('{"x": 42}')
print(t.x) -- 42
```
---
### `maki.json.schema_validator()` {#maki-json-schema_validator}
```lua
maki.json.schema_validator({schema})
```
Compile a JSON Schema into a reusable validator object. Supports
draft-07, 2019-09, and 2020-12. Schema errors show up right away so
you catch mistakes before doing any real work.
**Parameters:**
- `{schema}` (`table`) JSON Schema as a Lua table.
**Returns:** ([`maki.json.SchemaValidator?`](#maki-json-SchemaValidator), `string?`) Validator, or nil plus an error.
**Example:**
```lua
local v, err = maki.json.schema_validator({
type = "object",
properties = { name = { type = "string" } },
required = { "name" },
})
local errs = v:validate({ name = "maki" })
assert(errs == nil)
```
## maki.json.SchemaValidator {#maki-json-SchemaValidator}
A compiled JSON Schema validator. Create one with `maki.json.schema_validator()` and reuse it to validate many values without recompiling the schema each time.
---
### `SchemaValidator:validate()` {#SchemaValidator-validate}
```lua
SchemaValidator:validate({value})
```
Check {value} against the compiled schema. Returns nil when the value is valid. When validation fails, returns a list of human-readable error strings.
**Parameters:**
- `{value}` (`any`) The Lua value to validate.
**Returns:** (`table?`) Array of error strings, or nil if valid.
**Example:**
```lua
local errs = validator:validate({ name = 123 })
if errs then
for _, msg in ipairs(errs) do print(msg) end
end
```
## maki.keymap {#maki-keymap}
Key mappings, modeled after `vim.keymap`. If you have written a
Neovim keymap plugin before, this will feel familiar.
```lua
maki.keymap.set("n", "", function()
print("hello")
end, { desc = "Say hello" })
```
---
### `maki.keymap.set()` {#maki-keymap-set}
```lua
maki.keymap.set({mode}, {lhs}, {rhs}, {opts?})
```
Bind a key to a Lua function, just like `vim.keymap.set`. Only
normal mode (`"n"`) is supported right now. If {lhs} is already
mapped, the old binding is replaced and a warning is logged.
**Parameters:**
- `{mode}` (`string`) Mode letter. Currently only `"n"` is accepted.
- `{lhs}` (`string`) Key in Vim notation, e.g. `""`, `""`, `"a"`.
- `{rhs}` (`function`) Called when the key is pressed.
- `{opts?}` (`table?`) Options:
- `desc` (`string`) short description shown in the keymap list.
**Example:**
```lua
maki.keymap.set("n", "", function()
print("toggle!")
end, { desc = "Toggle panel" })
```
---
### `maki.keymap.del()` {#maki-keymap-del}
```lua
maki.keymap.del({mode}, {lhs})
```
Remove the mapping for {lhs} in {mode}. Does nothing if no mapping
exists for that key.
**Parameters:**
- `{mode}` (`string`) Mode letter (reserved for future modes).
- `{lhs}` (`string`) Key to unmap, in Vim notation.
**Example:**
```lua
maki.keymap.del("n", "")
```
## maki.log {#maki-log}
Structured logging for plugins.
Each call emits a tracing event tagged with the calling plugin's name.
Messages show up in maki's log output, which you can view with `maki --log`.
```lua
maki.log.info("ready")
maki.log.warn("something looks off")
```
---
### `maki.log.debug()` {#maki-log-debug}
```lua
maki.log.debug({msg})
```
Emit a DEBUG-level log message. Useful for development and troubleshooting.
The message is tagged with the plugin name automatically.
**Parameters:**
- `{msg}` (`string`) Message to log.
**Example:**
```lua
maki.log.debug("loaded " .. #items .. " items")
```
---
### `maki.log.info()` {#maki-log-info}
```lua
maki.log.info({msg})
```
Emit an INFO-level log message. Good for normal operational events.
**Parameters:**
- `{msg}` (`string`) Message to log.
**Example:**
```lua
maki.log.info("plugin initialized")
```
---
### `maki.log.warn()` {#maki-log-warn}
```lua
maki.log.warn({msg})
```
Emit a WARN-level log message. Use for recoverable problems.
**Parameters:**
- `{msg}` (`string`) Message to log.
**Example:**
```lua
maki.log.warn("config file missing, using defaults")
```
---
### `maki.log.error()` {#maki-log-error}
```lua
maki.log.error({msg})
```
Emit an ERROR-level log message. Use for failures that need attention.
**Parameters:**
- `{msg}` (`string`) Message to log.
**Example:**
```lua
maki.log.error("failed to connect to API")
```
## maki.model {#maki-model}
The model behind the focused session. Good for a keybind that flips
between your two go-to models, or lifts thinking for one hard question.
Without an interactive UI every function returns
`nil, "no interactive UI attached"`.
---
### `maki.model.get()` {#maki-model-get}
```lua
maki.model.get()
```
Reads the focused session's model, thinking level, and fast mode.
`thinking` comes back in the spelling `set` accepts, so a table from here
can go straight back in.
`thinking_options` is every thinking value this model accepts, cheapest
first: `{name, tokens?}` per row, where `tokens` is the budget maki would
send for that row and is absent on `off` and `adaptive`. It is empty exactly
when `supports_thinking` is false, so a picker can render the ladder from it
without knowing the levels.
**Returns:** (`table|nil`, `string|nil`) `{spec, id, provider, thinking,
thinking_options, fast, supports_thinking, supports_fast}`, or nil and an
error.
**Example:**
```lua
local m = maki.model.get()
if m.spec ~= "anthropic/claude-opus-4-6" then ... end
for _, option in ipairs(m.thinking_options) do
print(option.name, option.tokens)
end
```
---
### `maki.model.available()` {#maki-model-available}
```lua
maki.model.available()
```
Lists the model specs you can switch to: what the providers you are logged
into offer, minus what your model policy blocks. The list fills in the
background at startup, so right after launch it can still be empty.
**Returns:** (`table|nil`, `string|nil`) Array of `"provider/id"` specs, or nil and an error.
**Example:**
```lua
local specs = maki.model.available()
```
---
### `maki.model.set()` {#maki-model-set}
```lua
maki.model.set({opts})
```
Switches the focused session's model, thinking level, or fast mode. Fields
you leave out stay as they are, so this doubles as a thinking-only switch.
Answers with the new state, in the same shape `get` returns.
**Parameters:**
- `{opts}` (`string|table`) A model spec, or a table with any of:
- `spec` (`string`) `"provider/id"`, as listed by `available()`;
- `thinking` (`string|number`) `"off"`, `"adaptive"`, an effort level
(`"minimal"` to `"max"`), a token budget, or `""` to toggle it on and off;
- `fast` (`boolean`) Anthropic fast mode.
**Returns:** (`table|nil`, `string|nil`) The new state, or nil and an error.
**Example:**
```lua
maki.model.set("anthropic/claude-opus-4-6")
maki.model.set({ spec = "zai/glm-5", thinking = "high" })
maki.keymap.set("n", "", function() maki.model.set({ thinking = "" }) end)
```
## maki.net {#maki-net}
HTTP client for fetching web content. All traffic goes over HTTPS
(plain HTTP is upgraded). Private and metadata IP addresses are
blocked to prevent SSRF, including after a redirect. Hosts listed in
the `net.allowed_private_hosts` config option are exempt.
Failed requests (5xx) are retried automatically.
```lua
local res, err = maki.net.request("https://example.com")
if res then print(res.body) end
```
---
### `maki.net.request()` {#maki-net-request}
```lua
maki.net.request({url}, {opts?})
```
Make an HTTP request and return the response body. Plain `http://`
URLs are automatically upgraded to `https://`. Requests to private
or metadata IP addresses are blocked for safety, unless the host is
listed in `net.allowed_private_hosts`.
{opts} fields:
`method` (string) HTTP verb (default `"GET"`).
`headers` (table) Header name/value pairs.
`body` (string) Request body.
`timeout` (integer) Timeout in seconds, max 120 (default 30).
`max_bytes` (integer) Max response size in bytes (default 5 MB).
`retry` (integer) Retries on 5xx errors (default 3).
The response table has three fields: `body` (string), `status`
(integer), and `content_type` (string).
Requires the `net` [plugin permission](#plugin-permissions).
**Parameters:**
- `{url}` (`string`) URL starting with `http://` or `https://`.
- `{opts?}` (`table?`) Request options (see above).
**Returns:** (`table?`, `string?`) Response table, or nil plus an error string.
**Example:**
```lua
local res, err = maki.net.request("https://httpbin.org/get")
if err then
print("failed: " .. err)
else
print(res.status, res.body)
end
```
## maki.session {#maki-session}
Host session primitives. The interactive UI can run several sessions
at once; these functions let plugins list, create, focus, rename, and
delete them. Session management returns `nil, "no interactive UI
attached"` without a UI. `notify` instead targets a live agent mailbox
directly, so it also works under ACP and SDK frontends.
---
### `maki.session.list()` {#maki-session-list}
```lua
maki.session.list()
```
Lists sessions stored for the current project. Answered from a
background scan, so a slow disk never blocks the UI.
**Returns:** (`table|nil`, `string|nil`) Array of `{id, title, updated_at}`, or nil and an error.
**Example:**
```lua
local stored, err = maki.session.list()
```
---
### `maki.session.live()` {#maki-session-live}
```lua
maki.session.live()
```
Lists the sessions currently running in this UI. Status is "working",
"needs_input", or "idle". A mailbox follow-up stays "working" without an
intermediate "idle" status.
**Returns:** (`table|nil`, `string|nil`) Array of `{id, title, status, updated_at, focused}`, or nil and an error.
**Example:**
```lua
local live, err = maki.session.live()
```
---
### `maki.session.current()` {#maki-session-current}
```lua
maki.session.current()
```
Returns the id of the currently focused session.
**Returns:** (`string|nil`, `string|nil`) Session id, or nil and an error.
**Example:**
```lua
local id = maki.session.current()
```
---
### `maki.session.read()` {#maki-session-read}
```lua
maki.session.read({opts?})
```
One-call snapshot of a session: queue, usage, context, cost, mode, and
status. Reads the focused session, or the one you name in `session` when
you act on a background tab.
The returned table:
```
{
id, cwd, model, mode = "build" | "plan",
status = "idle" | "working" | "needs_input",
focused, updated_at,
usage = { input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens },
context_size, context_window,
cost,
queue = { count }, -- nil under headless drivers
title, -- nil under headless drivers
}
```
`usage` and `cost` include subagent spend. `context_size` is the main
session's own, since a subagent runs its own window. There is no
`list_cost` here because `cost` is re-settled from stored usage when a
session resumes and list price is not stored, so per-turn list price
lives on the `TurnEnd` autocmd instead.
**Parameters:**
- `{opts?}` (`table?`) `session` (string?) Session id; defaults to focused.
**Returns:** (`table|nil`, `string|nil`) Snapshot table, or nil and an error.
**Example:**
```lua
local s = maki.session.read()
if s.context_size > s.context_window * 0.8 then
maki.ui.notify("context is nearly full")
end
```
---
### `maki.session.focus()` {#maki-session-focus}
```lua
maki.session.focus({id})
```
Switches the UI to the session with {id}.
**Parameters:**
- `{id}` (`string`) Session id, as returned by `list()` or `live()`.
**Returns:** (`boolean|nil`, `string|nil`) true on success, or nil and an error.
**Example:**
```lua
local _, err = maki.session.focus(id)
```
---
### `maki.session.delete()` {#maki-session-delete}
```lua
maki.session.delete({id})
```
Deletes a session and its stored history, cancelling it first if it
is running. The focused session cannot be deleted.
**Parameters:**
- `{id}` (`string`) Session id to delete.
**Returns:** (`boolean|nil`, `string|nil`) true on success, or nil and an error.
**Example:**
```lua
local _, err = maki.session.delete(id)
```
---
### `maki.session.new()` {#maki-session-new}
```lua
maki.session.new({opts?})
```
Starts a new session in the current project.
**Parameters:**
- `{opts?}` (`table?`) Optional fields: prompt (string) first user message
to submit right away; focus (boolean) switch the UI to the new session.
**Returns:** (`string|nil`, `string|nil`) New session id, or nil and an error.
**Example:**
```lua
local id, err = maki.session.new({ prompt = "fix the tests", focus = true })
```
---
### `maki.session.prompt()` {#maki-session-prompt}
```lua
maki.session.prompt({text}, {opts?})
```
Sends {text} as a regular user prompt to a live session. The text is
never interpreted: slash commands, `exit`, and `!` shell prefixes are
all sent to the model verbatim. If the session is currently streaming,
the prompt is queued and picked up when the agent reaches it.
**Parameters:**
- `{text}` (`string`) The prompt to send. Must not be blank.
- `{opts?}` (`table?`) Optional fields: session (string) id of a live
session; defaults to the focused one.
**Returns:** (`string|nil`, `string|nil`) "started" or "queued", or nil and an error.
**Example:**
```lua
local state, err = maki.session.prompt("run the tests", { session = id })
```
---
### `maki.session.notify()` {#maki-session-notify}
```lua
maki.session.notify({text}, {opts?})
```
Reports {text} to a live session without creating a user turn. The
observation waits for the session's next agent run.
**Parameters:**
- `{text}` (`string`) What to report. Must not be blank.
- `{opts?}` (`table`) Options:
- `session` (`string`) id of a live session.
- `wake` (`boolean`) start a TUI turn when it next becomes idle (default false).
**Returns:** (`boolean|nil`, `string|nil`) true, or nil and an error.
**Example:**
```lua
maki.session.notify("[monitor] deploy failed", { session = id, wake = true })
```
---
### `maki.session.set_title()` {#maki-session-set_title}
```lua
maki.session.set_title({opts})
```
Renames a session, live or stored.
**Parameters:**
- `{opts}` (`table`) Required fields: id (string) session to rename;
- `title` (`string`) the new title.
**Returns:** (`boolean|nil`, `string|nil`) true on success, or nil and an error.
**Example:**
```lua
local _, err = maki.session.set_title({ id = id, title = "refactor" })
```
## maki.Timer {#maki-Timer}
Handle returned by `maki.defer_fn`. Its `:stop()` cancels the
callback before it fires, which is what debouncing is built on.
---
### `Timer:stop()` {#Timer-stop}
```lua
Timer:stop()
```
Cancel the pending callback. Safe to call more than once, and does
nothing once the callback has already run.
**Example:**
```lua
local h = maki.defer_fn(function() rebuild() end, 300)
h:stop()
```
## maki.task {#maki-task}
The subagents of the focused session and their transcripts. Tasks are
spawned by the `task` tool and addressed by an id that survives a reload.
Without an interactive UI every function returns
`nil, "no interactive UI attached"`.
---
### `maki.task.list()` {#maki-task-list}
```lua
maki.task.list()
```
Lists the focused session's chats in chat order. Entry 1 is always the main
chat, with id `"main"` and no `status`: its work is the session's own, and
`maki.session.live()` already reports that. The rest are subagents, keyed by
the tool call that spawned them.
**Returns:** (`table|nil`, `string|nil`) Array of `{id, name, focused, status?}` where
`status` is `"working"`, `"done"`, or `"error"`, or nil and an error.
**Example:**
```lua
for _, t in ipairs(maki.task.list() or {}) do
print(t.name, t.status or "main")
end
```
---
### `maki.task.focus()` {#maki-task-focus}
```lua
maki.task.focus({id})
```
Shows a task's transcript, the way the chat cycling keys do. An id from
another session returns an error instead of landing on the wrong task.
**Parameters:**
- `{id}` (`string`) Task id, as returned by `list()`. `"main"` is the main chat.
**Returns:** (`boolean|nil`, `string|nil`) true on success, or nil and an error.
**Example:**
```lua
local _, err = maki.task.focus("main")
```
## maki.text {#maki-text}
Text transformation utilities.
Helper functions for converting between text formats.
```lua
local md = maki.text.html_to_markdown(html)
```
---
### `maki.text.html_to_markdown()` {#maki-text-html_to_markdown}
```lua
maki.text.html_to_markdown({html})
```
Convert an HTML string to Markdown.
Useful for cleaning up web content fetched with `maki.webfetch`.
**Parameters:**
- `{html}` (`string`) HTML source text.
**Returns:** (`string?`, `string?`) Markdown text on success, or nil plus an error message.
**Example:**
```lua
local md, err = maki.text.html_to_markdown("Hello
world
")
if err then return end
print(md) -- "# Hello\n\nworld"
```
## maki.treesitter {#maki-treesitter}
Tree-sitter parsing and query API.
Mirrors `vim.treesitter` from Neovim, so plugins can be shared between the two.
Start with `get_parser()` to parse source code, then use `get_node_text()` and
the `query` sub-module to extract information from the syntax tree.
```lua
local parser, err = maki.treesitter.get_parser(source, "lua")
local trees = parser:parse()
local root = trees[1]:root()
```
---
### `maki.treesitter.get_parser()` {#maki-treesitter-get_parser}
```lua
maki.treesitter.get_parser({source}, {lang})
```
Creates a `LanguageTree` for {source} using the grammar named {lang}.
This is the main entry point for parsing source code with tree-sitter.
Signature matches `vim.treesitter.get_parser()`, so Neovim plugins can be copy-pasted.
**Parameters:**
- `{source}` (`string`) Source text to parse.
- `{lang}` (`string`) Language name, e.g. `"rust"` or `"lua"`.
**Returns:** ([`LanguageTree|nil`](#maki-treesitter-LanguageTree), `string|nil`) Parser, or nil and an error message.
**Example:**
```lua
local parser, err = maki.treesitter.get_parser(src, "lua")
if err then print("error: " .. err) end
```
---
### `maki.treesitter.get_string_parser()` {#maki-treesitter-get_string_parser}
```lua
maki.treesitter.get_string_parser({source}, {lang})
```
Alias for `get_parser`. Use whichever name you prefer.
**Parameters:**
- `{source}` (`string`) Source text to parse.
- `{lang}` (`string`) Language name.
**Returns:** ([`LanguageTree|nil`](#maki-treesitter-LanguageTree), `string|nil`) Parser, or nil and an error message.
---
### `maki.treesitter.get_node_text()` {#maki-treesitter-get_node_text}
```lua
maki.treesitter.get_node_text({node}, {source})
```
Gets the text that {node} covers in {source}.
Useful when you have a captured node and need the actual source substring.
**Parameters:**
- `{node}` ([`Node`](#maki-treesitter-Node)) The node whose text you want.
- `{source}` (`string`) Original source text the tree was parsed from.
**Returns:** (`string`) Substring covered by the node.
**Example:**
```lua
local text = maki.treesitter.get_node_text(node, source)
print(text)
```
---
### `maki.treesitter.get_node_range()` {#maki-treesitter-get_node_range}
```lua
maki.treesitter.get_node_range({node})
```
Returns the range of {node} as four 0-based integers: start_row, start_col, end_row, end_col.
**Parameters:**
- `{node}` ([`Node`](#maki-treesitter-Node)) The node to query.
**Returns:** (`integer`, `integer`, `integer`, `integer`) start_row, start_col, end_row, end_col.
**Example:**
```lua
local sr, sc, er, ec = maki.treesitter.get_node_range(node)
```
---
### `maki.treesitter.get_range()` {#maki-treesitter-get_range}
```lua
maki.treesitter.get_range({node})
```
Returns a six-element table for {node}: `{start_row, start_col, start_byte, end_row, end_col, end_byte}`.
This gives you byte offsets in addition to row/column positions.
**Parameters:**
- `{node}` ([`Node`](#maki-treesitter-Node)) The node to query.
**Returns:** (`table`) Six-element array: start_row, start_col, start_byte, end_row, end_col, end_byte.
**Example:**
```lua
local r = maki.treesitter.get_range(node)
print("bytes: " .. r[3] .. "-" .. r[6])
```
---
### `maki.treesitter.is_ancestor()` {#maki-treesitter-is_ancestor}
```lua
maki.treesitter.is_ancestor({dest}, {source})
```
Checks whether {dest} is an ancestor of {source} (or the same node).
Walks up from {source} toward the root looking for {dest}.
**Parameters:**
- `{dest}` ([`Node`](#maki-treesitter-Node)) Potential ancestor node.
- `{source}` ([`Node`](#maki-treesitter-Node)) Node to check ancestry for.
**Returns:** (`boolean`)
---
### `maki.treesitter.is_in_node_range()` {#maki-treesitter-is_in_node_range}
```lua
maki.treesitter.is_in_node_range({node}, {line}, {col})
```
Checks whether the 0-based position ({line}, {col}) falls inside {node}.
Handy for cursor-position checks.
**Parameters:**
- `{node}` ([`Node`](#maki-treesitter-Node)) Node to test against.
- `{line}` (`integer`) 0-based line number.
- `{col}` (`integer`) 0-based column number.
**Returns:** (`boolean`)
---
### `maki.treesitter.node_contains()` {#maki-treesitter-node_contains}
```lua
maki.treesitter.node_contains({node}, {range})
```
Checks whether {node} fully contains the given {range}.
**Parameters:**
- `{node}` ([`Node`](#maki-treesitter-Node)) Node to test.
- `{range}` (`table`) Four-element array `{start_row, start_col, end_row, end_col}`.
**Returns:** (`boolean`)
---
### `maki.treesitter.get_node()` {#maki-treesitter-get_node}
```lua
maki.treesitter.get_node({opts?})
```
Placeholder for cursor-based node lookup (not yet implemented, always returns nil).
**Parameters:**
- `{opts?}` (`table?`) Options (currently unused).
**Returns:** ([`Node|nil`](#maki-treesitter-Node)) Always nil.
## maki.treesitter.language {#maki-treesitter-language}
Language registry for tree-sitter grammars.
Mirrors `vim.treesitter.language`. Use these functions to register grammars,
map filetypes to languages, and inspect available node types.
```lua
maki.treesitter.language.add("lua")
maki.treesitter.language.register("lua", "luau")
```
---
### `maki.treesitter.language.add()` {#maki-treesitter-language-add}
```lua
maki.treesitter.language.add({lang}, {opts?})
```
Registers {lang} for use with tree-sitter.
Call this to confirm a language grammar is available. Throws if {lang} is unknown.
Custom grammar paths are not yet supported.
**Parameters:**
- `{lang}` (`string`) Language name, e.g. `"rust"`.
- `{opts?}` (`table?`) Options table (the `path` key is not yet supported).
**Example:**
```lua
maki.treesitter.language.add("lua")
```
---
### `maki.treesitter.language.register()` {#maki-treesitter-language-register}
```lua
maki.treesitter.language.register({lang}, {filetype})
```
Associates {lang} with one or more filetypes, so you can look up the right
parser language for a given filetype later with `get_lang()`.
**Parameters:**
- `{lang}` (`string`) Language name.
- `{filetype}` (`string|table`) A single filetype string or an array of filetype strings.
**Example:**
```lua
maki.treesitter.language.register("typescript", { "ts", "tsx" })
```
---
### `maki.treesitter.language.get_lang()` {#maki-treesitter-language-get_lang}
```lua
maki.treesitter.language.get_lang({filetype})
```
Looks up the tree-sitter language name for {filetype}.
Returns the registered language, or falls back to {filetype} itself if
a grammar with that name exists. Returns nil when nothing matches.
**Parameters:**
- `{filetype}` (`string`) Filetype to look up, e.g. `"ts"`.
**Returns:** (`string|nil`) Language name, or nil.
**Example:**
```lua
local lang = maki.treesitter.language.get_lang("tsx")
if lang then print(lang) end -- "typescript"
```
---
### `maki.treesitter.language.get_filetypes()` {#maki-treesitter-language-get_filetypes}
```lua
maki.treesitter.language.get_filetypes({lang})
```
Returns all filetypes that have been registered for {lang}.
**Parameters:**
- `{lang}` (`string`) Language name.
**Returns:** (`table`) Array of filetype strings.
**Example:**
```lua
local fts = maki.treesitter.language.get_filetypes("typescript")
-- { "ts", "tsx" }
```
---
### `maki.treesitter.language.inspect()` {#maki-treesitter-language-inspect}
```lua
maki.treesitter.language.inspect({lang})
```
Returns metadata about the grammar for {lang}.
Useful for debugging or discovering which node types and fields a grammar defines.
**Parameters:**
- `{lang}` (`string`) Language name.
**Returns:** (`table`) Table with keys `abi_version` (integer), `node_types` (string[]), `fields` (string[]).
**Example:**
```lua
local info = maki.treesitter.language.inspect("lua")
print("ABI: " .. info.abi_version)
for _, nt in ipairs(info.node_types) do print(nt) end
```
## maki.treesitter.query {#maki-treesitter-query}
Query compilation and lookup.
Mirrors `vim.treesitter.query`. Use `parse()` to compile a tree-sitter
query string into a `Query` object you can run against parsed trees.
```lua
local q = maki.treesitter.query.parse("lua", "(string) @str")
```
---
### `maki.treesitter.query.parse()` {#maki-treesitter-query-parse}
```lua
maki.treesitter.query.parse({lang}, {query})
```
Compiles a tree-sitter query string for {lang}.
Throws if the language is unknown or the query has a syntax error.
**Parameters:**
- `{lang}` (`string`) Language name, e.g. `"lua"`.
- `{query}` (`string`) Tree-sitter S-expression query.
**Returns:** ([`Query`](#maki-treesitter-Query)) Compiled query object.
**Example:**
```lua
local q = maki.treesitter.query.parse("lua", "(identifier) @id")
```
---
### `maki.treesitter.query.get()` {#maki-treesitter-query-get}
```lua
maki.treesitter.query.get({lang}, {name})
```
Looks up a named built-in query for {lang} (not yet implemented, always returns nil).
**Parameters:**
- `{lang}` (`string`) Language name.
- `{name}` (`string`) Query name, e.g. `"highlights"`.
**Returns:** ([`Query|nil`](#maki-treesitter-Query)) Query object, or nil if not found.
## maki.treesitter.Query {#maki-treesitter-Query}
A compiled tree-sitter query.
Get one by calling `maki.treesitter.query.parse(lang, query_string)`.
Then use `:iter_captures()` or `:iter_matches()` to run it against a syntax tree.
```lua
local q = maki.treesitter.query.parse("lua", "(identifier) @id")
for idx, node, meta in q:iter_captures(root, source) do
print(node:type())
end
```
---
### `Query:iter_captures()` {#Query-iter_captures}
```lua
Query:iter_captures({node}, {source}, {start_row?}, {stop_row?})
```
Iterates over every capture matched by this query. Each call to the returned iterator yields `(capture_index, node, metadata, match, active)`. Use this when you care about individual captures rather than whole pattern matches.
**Parameters:**
- `{node}` ([`Node`](#maki-treesitter-Node)) Root node to search within.
- `{source}` (`string`) Source text the tree was parsed from.
- `{start_row?}` (`integer`) Only match rows >= this value (0-based).
- `{stop_row?}` (`integer`) Only match rows < this value (0-based).
**Returns:** (`function`) Iterator yielding (integer, Node, table, table, integer).
**Example:**
```lua
local q = maki.treesitter.query.parse("lua", "(identifier) @id")
for idx, node, meta in q:iter_captures(root, source) do
print(idx, node:type())
end
```
---
### `Query:iter_matches()` {#Query-iter_matches}
```lua
Query:iter_matches({node}, {source}, {start_row?}, {stop_row?})
```
Iterates over every full pattern match in this query. Each call to the returned iterator yields `(pattern_index, captures, metadata, active)` where captures is a table keyed by capture index. Use this when you need all captures for a pattern together.
**Parameters:**
- `{node}` ([`Node`](#maki-treesitter-Node)) Root node to search within.
- `{source}` (`string`) Source text the tree was parsed from.
- `{start_row?}` (`integer`) Only match rows >= this value (0-based).
- `{stop_row?}` (`integer`) Only match rows < this value (0-based).
**Returns:** (`function`) Iterator yielding (integer, table, table, integer).
**Example:**
```lua
local q = maki.treesitter.query.parse("lua", "(function_declaration name: (identifier) @name)"
)
for pat, captures, meta in q:iter_matches(root, source) do
for cap_idx, nodes in pairs(captures) do
print(nodes[1]:type())
end
end
```
## maki.treesitter.Tree {#maki-treesitter-Tree}
A parsed syntax tree.
Obtained from `LanguageTree:parse()` or `LanguageTree:trees()`.
Call `:root()` to get the root node and start traversing.
```lua
local trees = parser:parse()
local root = trees[1]:root()
```
---
### `Tree:root()` {#Tree-root}
```lua
Tree:root()
```
Returns the root node of this tree. This is where you start walking
the syntax tree or running queries.
**Returns:** ([`Node`](#maki-treesitter-Node)) Root node.
**Example:**
```lua
local root = tree:root()
print(root:type()) -- e.g. "chunk" for Lua
```
---
### `Tree:copy()` {#Tree-copy}
```lua
Tree:copy()
```
Returns an independent copy of this tree.
Edits to the copy will not affect the original.
**Returns:** ([`Tree`](#maki-treesitter-Tree)) A new Tree with the same content.
## maki.treesitter.Node {#maki-treesitter-Node}
A single node in a parsed syntax tree.
Nodes are obtained from `Tree:root()`, navigation methods like `:child()`,
or from query captures. Each node knows its type, range, and children.
```lua
local root = tree:root()
print(root:type(), root:child_count())
for child, field in root:iter_children() do
print(child:type(), field)
end
```
---
### `Node:type()` {#Node-type}
```lua
Node:type()
```
Returns the grammar type name for this node, like `"function_definition"` or `"identifier"`.
**Returns:** (`string`) Grammar type name.
---
### `Node:symbol()` {#Node-symbol}
```lua
Node:symbol()
```
Returns the numeric symbol id for this node's grammar type.
Two nodes with the same type always share the same symbol id.
**Returns:** (`integer`) Symbol id.
---
### `Node:id()` {#Node-id}
```lua
Node:id()
```
Returns a unique string identifier for this specific node in the tree.
Useful for deduplication or as a table key.
**Returns:** (`string`) Node identity string.
---
### `Node:range()` {#Node-range}
```lua
Node:range({include_bytes?})
```
Returns the range of this node as multiple return values.
Without {include_bytes}: `start_row, start_col, end_row, end_col`.
With {include_bytes} set to true: `start_row, start_col, start_byte, end_row, end_col, end_byte`.
**Parameters:**
- `{include_bytes?}` (`boolean`) When true, byte offsets are included in the return values.
**Returns:** (`integer`, `integer`, `integer`, `integer`) Four values, or six when include_bytes is true.
**Example:**
```lua
local sr, sc, er, ec = node:range()
local sr, sc, sb, er, ec, eb = node:range(true)
```
---
### `Node:start()` {#Node-start}
```lua
Node:start()
```
Returns the start position of this node: row, column, and byte offset (all 0-based).
**Returns:** (`integer`, `integer`, `integer`) start_row, start_col, start_byte.
---
### `Node:end_()` {#Node-end_}
```lua
Node:end_()
```
Returns the end position of this node: row, column, and byte offset (all 0-based).
**Returns:** (`integer`, `integer`, `integer`) end_row, end_col, end_byte.
---
### `Node:byte_length()` {#Node-byte_length}
```lua
Node:byte_length()
```
Returns how many bytes this node spans in the source text.
**Returns:** (`integer`) Byte length.
---
### `Node:child()` {#Node-child}
```lua
Node:child({index})
```
Returns the child at position {index} (0-based), including anonymous nodes like punctuation.
Returns nil if {index} is out of bounds.
**Parameters:**
- `{index}` (`integer`) 0-based child index.
**Returns:** ([`Node|nil`](#maki-treesitter-Node)) Child node, or nil.
---
### `Node:named_child()` {#Node-named_child}
```lua
Node:named_child({index})
```
Returns the named child at position {index} (0-based), skipping anonymous nodes.
Returns nil if {index} is out of bounds.
**Parameters:**
- `{index}` (`integer`) 0-based named child index.
**Returns:** ([`Node|nil`](#maki-treesitter-Node)) Named child node, or nil.
---
### `Node:child_count()` {#Node-child_count}
```lua
Node:child_count()
```
Returns the total number of children, including anonymous nodes.
**Returns:** (`integer`) Child count.
---
### `Node:named_child_count()` {#Node-named_child_count}
```lua
Node:named_child_count()
```
Returns the number of named children (skipping anonymous punctuation nodes).
**Returns:** (`integer`) Named child count.
---
### `Node:children()` {#Node-children}
```lua
Node:children()
```
Returns all children (named and anonymous) as a Lua table.
**Returns:** (`table`) Array of Node.
**Example:**
```lua
for _, child in ipairs(node:children()) do
print(child:type())
end
```
---
### `Node:named_children()` {#Node-named_children}
```lua
Node:named_children()
```
Returns all named children as a Lua table, skipping anonymous nodes.
**Returns:** (`table`) Array of Node.
---
### `Node:iter_children()` {#Node-iter_children}
```lua
Node:iter_children()
```
Returns an iterator function that yields `(child, field_name)` for every child.
The field name is nil for children that are not assigned to a grammar field.
**Returns:** (`function`) Iterator yielding (Node, string|nil).
**Example:**
```lua
for child, field in node:iter_children() do
if field then print(field .. ": " .. child:type()) end
end
```
---
### `Node:field()` {#Node-field}
```lua
Node:field({name})
```
Returns all children assigned to the grammar field {name} as a table.
For example, a function node might have a `"name"` or `"body"` field.
**Parameters:**
- `{name}` (`string`) Field name defined in the grammar.
**Returns:** (`table`) Array of Node.
**Example:**
```lua
local bodies = node:field("body")
```
---
### `Node:parent()` {#Node-parent}
```lua
Node:parent()
```
Returns the parent of this node, or nil if this is the root.
**Returns:** ([`Node|nil`](#maki-treesitter-Node)) Parent node.
---
### `Node:next_sibling()` {#Node-next_sibling}
```lua
Node:next_sibling()
```
Returns the next sibling (named or anonymous), or nil if this is the last child.
**Returns:** ([`Node|nil`](#maki-treesitter-Node)) Next sibling.
---
### `Node:prev_sibling()` {#Node-prev_sibling}
```lua
Node:prev_sibling()
```
Returns the previous sibling (named or anonymous), or nil if this is the first child.
**Returns:** ([`Node|nil`](#maki-treesitter-Node)) Previous sibling.
---
### `Node:next_named_sibling()` {#Node-next_named_sibling}
```lua
Node:next_named_sibling()
```
Returns the next named sibling, skipping anonymous nodes. Returns nil at the end.
**Returns:** ([`Node|nil`](#maki-treesitter-Node)) Next named sibling.
---
### `Node:prev_named_sibling()` {#Node-prev_named_sibling}
```lua
Node:prev_named_sibling()
```
Returns the previous named sibling, skipping anonymous nodes. Returns nil at the start.
**Returns:** ([`Node|nil`](#maki-treesitter-Node)) Previous named sibling.
---
### `Node:child_with_descendant()` {#Node-child_with_descendant}
```lua
Node:child_with_descendant({descendant})
```
Finds the direct child of this node that contains {descendant}.
Returns nil if {descendant} is not actually inside this node.
**Parameters:**
- `{descendant}` ([`Node`](#maki-treesitter-Node)) A node that may be a descendant.
**Returns:** ([`Node|nil`](#maki-treesitter-Node)) Direct child containing the descendant.
---
### `Node:descendant_for_range()` {#Node-descendant_for_range}
```lua
Node:descendant_for_range({start_row}, {start_col}, {end_row}, {end_col})
```
Finds the smallest node inside this node that spans the given point range.
Includes both named and anonymous nodes.
**Parameters:**
- `{start_row}` (`integer`) Start row (0-based).
- `{start_col}` (`integer`) Start column (0-based).
- `{end_row}` (`integer`) End row (0-based).
- `{end_col}` (`integer`) End column (0-based).
**Returns:** ([`Node|nil`](#maki-treesitter-Node)) Smallest node covering the range, or nil.
---
### `Node:named_descendant_for_range()` {#Node-named_descendant_for_range}
```lua
Node:named_descendant_for_range({start_row}, {start_col}, {end_row}, {end_col})
```
Like `descendant_for_range`, but only considers named nodes.
**Parameters:**
- `{start_row}` (`integer`) Start row (0-based).
- `{start_col}` (`integer`) Start column (0-based).
- `{end_row}` (`integer`) End row (0-based).
- `{end_col}` (`integer`) End column (0-based).
**Returns:** ([`Node|nil`](#maki-treesitter-Node)) Smallest named node covering the range, or nil.
---
### `Node:named()` {#Node-named}
```lua
Node:named()
```
Returns true if this is a named node (not anonymous punctuation like `,` or `(`).
**Returns:** (`boolean`)
---
### `Node:extra()` {#Node-extra}
```lua
Node:extra()
```
Returns true if this node is an "extra" (like a comment) that can appear anywhere in the grammar.
**Returns:** (`boolean`)
---
### `Node:missing()` {#Node-missing}
```lua
Node:missing()
```
Returns true if this node is "missing", meaning it was inserted by the parser during error recovery.
**Returns:** (`boolean`)
---
### `Node:has_error()` {#Node-has_error}
```lua
Node:has_error()
```
Returns true if this node or any of its descendants contain a syntax error.
**Returns:** (`boolean`)
---
### `Node:has_changes()` {#Node-has_changes}
```lua
Node:has_changes()
```
Returns true if this node has been marked as changed since the last parse.
**Returns:** (`boolean`)
---
### `Node:equal()` {#Node-equal}
```lua
Node:equal({other})
```
Returns true if this node and {other} are the same node in the tree.
**Parameters:**
- `{other}` ([`Node`](#maki-treesitter-Node)) Node to compare against.
**Returns:** (`boolean`)
---
### `Node:sexpr()` {#Node-sexpr}
```lua
Node:sexpr()
```
Returns the S-expression (lisp-like) string for this node and its children.
Handy for debugging the tree structure.
**Returns:** (`string`) S-expression.
**Example:**
```lua
print(node:sexpr()) -- e.g. "(identifier)"
```
---
### `Node:tree()` {#Node-tree}
```lua
Node:tree()
```
Returns the Tree that this node belongs to.
**Returns:** ([`Tree`](#maki-treesitter-Tree)) The owning tree.
## maki.treesitter.LanguageTree {#maki-treesitter-LanguageTree}
Manages parsing of a source string for a single language.
Obtained from `maki.treesitter.get_parser()` or `maki.treesitter.get_string_parser()`.
Call `:parse()` to get the syntax tree, then use `:root()` on the tree to start walking nodes.
```lua
local parser, err = maki.treesitter.get_parser(source, "lua")
if not err then
local trees = parser:parse()
local root = trees[1]:root()
end
```
---
### `LanguageTree:parse()` {#LanguageTree-parse}
```lua
LanguageTree:parse({range?})
```
Parses the source and returns a table containing the resulting Tree.
The tree is cached, so calling this again is cheap.
**Parameters:**
- `{range?}` (`table`) Unused. Accepted for API compatibility.
**Returns:** (`table`) Array with one Tree element.
**Example:**
```lua
local trees = parser:parse()
local root = trees[1]:root()
```
---
### `LanguageTree:lang()` {#LanguageTree-lang}
```lua
LanguageTree:lang()
```
Returns the language name this parser was created with.
**Returns:** (`string`) Language name, e.g. `"lua"`.
---
### `LanguageTree:children()` {#LanguageTree-children}
```lua
LanguageTree:children()
```
Returns child LanguageTrees for injected languages.
Not yet implemented, always returns an empty table.
**Returns:** (`table`) Empty table.
---
### `LanguageTree:trees()` {#LanguageTree-trees}
```lua
LanguageTree:trees()
```
Returns all parsed trees as a table (at most one for now).
Returns an empty table if `parse()` has not been called yet.
**Returns:** (`table`) Array of Tree.
---
### `LanguageTree:source()` {#LanguageTree-source}
```lua
LanguageTree:source()
```
Returns the source string this parser was created with.
**Returns:** (`string`) The original source text.
---
### `LanguageTree:is_valid()` {#LanguageTree-is_valid}
```lua
LanguageTree:is_valid({exclude_children?}, {range?})
```
Checks whether the parse tree is still valid.
Not yet implemented, always returns true.
**Parameters:**
- `{exclude_children?}` (`boolean`) Unused.
- `{range?}` (`table`) Unused.
**Returns:** (`boolean`) Always true.
---
### `LanguageTree:for_each_tree()` {#LanguageTree-for_each_tree}
```lua
LanguageTree:for_each_tree({fn})
```
Calls {fn} with `(tree, nil)` for the parsed tree.
Triggers a parse if the tree has not been parsed yet.
**Parameters:**
- `{fn}` (`function`) Callback receiving `(Tree, nil)`.
**Example:**
```lua
parser:for_each_tree(function(tree, _)
print(tree:root():type())
end)
```
---
### `LanguageTree:included_regions()` {#LanguageTree-included_regions}
```lua
LanguageTree:included_regions()
```
Returns the regions this parser covers.
Not yet implemented, always returns a table with one empty region.
**Returns:** (`table`) Array with one empty table.
---
### `LanguageTree:contains()` {#LanguageTree-contains}
```lua
LanguageTree:contains({range})
```
Checks whether this parser covers the given {range}.
Not yet implemented, always returns true.
**Parameters:**
- `{range}` (`table`) Range to check (currently unused).
**Returns:** (`boolean`) Always true.
---
### `LanguageTree:destroy()` {#LanguageTree-destroy}
```lua
LanguageTree:destroy()
```
Drops the cached parse tree and frees its memory.
After calling this, the next `parse()` will re-parse from scratch.
## maki.ui {#maki-ui}
Functions for building interactive UI. Create buffers to hold
content, open floating or split windows to display them, highlight
code, render markdown, and show status hints.
```lua
local buf = maki.ui.buf()
buf:line("hello from my plugin!")
local win = maki.ui.open_win(buf, { title = "Greeting", width = "50%", height = 5 })
```
---
### `maki.ui.buf()` {#maki-ui-buf}
```lua
maki.ui.buf({opts?})
```
Creates a new buffer for building UI content. The first buffer
created in a task becomes the "live" buffer, streamed to the UI while
the tool runs, which is what the tool's own output pane wants. A
float that opens during a tool call would take that spot away, so
create its buffer with `{ scratch = true }`. It matches nvim's
`nvim_create_buf(false, true)`.
**Parameters:**
- `{opts?}` (`table?`) Optional. `scratch` (boolean) keeps the buffer out of the live slot, default false.
**Returns:** ([`Buf`](#maki-ui-Buf)) Buffer handle.
**Example:**
```lua
-- The tool's output pane:
local out = maki.ui.buf()
out:line("hello world")
-- A float raised during a tool call needs its own buffer:
local toast = maki.ui.buf({ scratch = true })
toast:line("copied!")
```
---
### `maki.ui.theme_color()` {#maki-ui-theme_color}
```lua
maki.ui.theme_color({name})
```
Looks up a semantic color from the current theme. Use this to keep
your plugin's colors consistent with the rest of the UI.
**Parameters:**
- `{name}` (`string`) Semantic color name, e.g. "accent" or "background".
**Returns:** (`string|nil`) "#rrggbb" for a truecolor theme, a palette index as a
string like "4" when the theme names an ANSI color, or "default" for the
terminal's own color. Nil only when the name is unknown. Every form can be
passed straight to a span's `fg`/`bg`.
**Example:**
```lua
local accent = maki.ui.theme_color("accent")
if accent then
buf:line({ { "note", { fg = accent, bold = true } } })
end
```
---
### `maki.ui.highlight()` {#maki-ui-highlight}
```lua
maki.ui.highlight({code}, {lang}, {opts?})
```
Syntax-highlights a chunk of source code. Returns a table of styled
lines that you can feed into a buffer. Each line is a list of
`{text, style}` spans where style is a `{fg, bold?, italic?, underline?}` table.
`fg` is "#rrggbb" for a truecolor theme, a palette index as a string like
"4" when the theme names an ANSI color, or "default" for the terminal's own
color. Pass the span straight to `buf:line` and it resolves correctly in
every case.
**Parameters:**
- `{code}` (`string`) Source text to highlight.
- `{lang}` (`string`) Language identifier, e.g. "rust", "python".
- `{opts?}` (`table?`) Options. Fields:
- `independent` (`boolean`) highlight each line without cross-line context. Default false.
- `prefix` (`string`) prepend to the source before highlighting (affects token context). Default "".
**Returns:** (`table`) Lines: `{ { {text, style}, ... }, ... }`. Each style is `{fg, bold?, italic?, underline?}`.
**Example:**
```lua
local lines = maki.ui.highlight("fn main() {}", "rust")
for _, spans in ipairs(lines) do
buf:line(spans)
end
```
---
### `maki.ui.markdown()` {#maki-ui-markdown}
```lua
maki.ui.markdown({text}, {width})
```
Renders Markdown into styled lines ready to display in a buffer.
Each span's style is either a named string ("bold", "heading",
"inline_code", etc.) or a `{fg, bold?, italic?, underline?}` table
for syntax-highlighted code blocks.
**Parameters:**
- `{text}` (`string`) Markdown source.
- `{width}` (`integer`) Wrap width in columns.
**Returns:** (`table`) Lines: `{ { {text, style}, ... }, ... }`.
**Example:**
```lua
local size = maki.ui.terminal_size()
local lines = maki.ui.markdown("# Hello\n\nSome **bold** text.", size.cols)
for _, spans in ipairs(lines) do
buf:line(spans)
end
```
---
### `maki.ui.humantime()` {#maki-ui-humantime}
```lua
maki.ui.humantime({secs})
```
Formats a number of seconds into a short, human-friendly string.
Useful for displaying elapsed time in status messages.
**Parameters:**
- `{secs}` (`integer`) Duration in seconds.
**Returns:** (`string`) Human-readable duration, e.g. "1m30s".
**Example:**
```lua
maki.ui.humantime(90) -- "1m30s"
maki.ui.humantime(3661) -- "1h1m1s"
```
---
### `maki.ui.terminal_size()` {#maki-ui-terminal_size}
```lua
maki.ui.terminal_size()
```
Returns the current terminal size. Handy for sizing floating windows
or wrapping text to fit the screen.
**Returns:** (`table`) `{cols, rows}`, terminal width and height in characters.
**Example:**
```lua
local size = maki.ui.terminal_size()
local half_width = math.floor(size.cols / 2)
```
---
### `maki.ui.display_width()` {#maki-ui-display_width}
```lua
maki.ui.display_width({text})
```
Returns the display width of a string in terminal cells, matching
how `ratatui` measures text.
**Parameters:**
- `{text}` (`string`) The text to measure.
**Returns:** (`integer`) Number of display cells the text occupies.
**Example:**
```lua
local w = maki.ui.display_width("hello")
```
---
### `maki.ui.truncate_text()` {#maki-ui-truncate_text}
```lua
maki.ui.truncate_text({text}, {max_width})
```
Splits a string at a display-cell boundary.
**Parameters:**
- `{text}` (`string`) The text to split.
- `{max_width}` (`integer`) Maximum display cells for the head.
**Returns:** (`table`) `{head = string, tail = string}`.
**Example:**
```lua
local t = maki.ui.truncate_text("hello world", 5)
-- t.head == "hello", t.tail == " world"
```
---
### `maki.ui.flash()` {#maki-ui-flash}
```lua
maki.ui.flash({msg})
```
Shows a brief message in the status bar. The message disappears
after a short time. Good for confirming an action like "copied!"
or showing a transient warning.
**Parameters:**
- `{msg}` (`string`) Message text.
**Example:**
```lua
maki.ui.flash("Copied to clipboard!")
```
---
### `maki.ui.action()` {#maki-ui-action}
```lua
maki.ui.action({name})
```
Runs a built-in UI action by name, exactly as its default keybinding
would. Handy when a default key never reaches maki because tmux or
your terminal grabs it first: bind a new key with `maki.keymap.set`
and call this from it.
Valid names: `"file_picker"`, `"search"`, `"help"`,
`"plan_toggle"`, `"plan_editor"`, `"edit_input"`, `"pop_queue"`,
`"prev_chat"`, `"next_chat"`, `"model_picker"`.
For slash commands rather than keybound actions, see
`maki.api.run_command`.
**Parameters:**
- `{name}` (`string`) Action name, e.g. `"file_picker"`.
**Returns:** (`boolean|nil`, `string|nil`) `true` on success, or nil and an error message for an unknown name.
**Example:**
```lua
-- Open the built-in file picker with Ctrl+Q instead of Ctrl+S:
maki.keymap.set("n", "", function()
maki.ui.action("file_picker")
end)
```
---
### `maki.ui.open_editor()` {#maki-ui-open_editor}
```lua
maki.ui.open_editor({path})
```
Opens {path} in the user's `$EDITOR` (e.g. vim, nano) and waits for
it to close. This suspends the TUI while the editor is running.
Returns the editor's exit code so you can check if the user saved.
**Parameters:**
- `{path}` (`string`) File to open.
**Returns:** (`integer`) Editor exit code, or -1 if the action could not be dispatched.
**Example:**
```lua
local code = maki.ui.open_editor("/tmp/scratch.lua")
if code == 0 then
maki.ui.flash("File saved")
end
```
---
### `maki.ui.open_win()` {#maki-ui-open_win}
```lua
maki.ui.open_win({buf}, {opts})
```
Opens a floating or split window that displays the contents of {buf}.
Returns a Win handle you can use to receive events, update layout,
and close the window when you are done.
**Parameters:**
- `{buf}` ([`Buf`](#maki-ui-Buf)) Buffer to display.
- `{opts}` (`table`) Float configuration. Fields:
- `width` (`integer|string`) window width. Integer for absolute columns; "N%" for percent of terminal width. Default "60%".
- `height` (`integer|string`) window height. Integer for absolute rows; "N%" for percent of terminal height. Default "70%".
- `row` (`integer?`) row offset from the anchor corner. Negative values move up.
- `col` (`integer?`) column offset from the anchor corner.
- `anchor` (`string`) corner the (row, col) offset is relative to. One of "NW" (default), "NE", "SW", "SE".
- `border` (`string`) border style. One of "rounded" (default), "single", "double", "none".
- `title` (`string`) text shown in the top border. Default "".
- `title_pos` (`string`) title alignment. One of "left" (default), "center", "right".
- `footer` (`table`) key-hint pairs shown in the bottom border. Each entry is {key, label}.
- `zindex` (`integer`) stacking order. Default 50.
- `cursor_line` (`boolean`) highlight the focused row. Default false.
- `reserved_top` (`integer`) rows reserved at the top of the content area. Default 0.
- `reserved_bottom` (`integer`) rows reserved at the bottom of the content area. Default 0.
- `split` (`string`) dock the window to an edge instead of floating. One of "above", "below", "left", "right", "panel", or "" (floating, default).
- `order` (`integer`) paint order among split windows at the same edge. Default 50.
- `focus` (`boolean`) whether the window takes keyboard focus on open. Default true.
- `visible` (`boolean`) whether the window is initially visible. Default true.
- `needs_input` (`boolean`) whether the window means the session needs user input. Default false.
- `stack` (`boolean`) offset the window past the other stacked windows sharing its anchor, in open order, with a one row gap. Closing one moves the rest up. Floating windows only. Default false.
**Returns:** ([`Win`](#maki-ui-Win)) Window handle.
**Example:**
```lua
local buf = maki.ui.buf()
buf:line("Pick an option:")
local win = maki.ui.open_win(buf, {
title = "Menu",
width = "50%",
height = 10,
cursor_line = true,
footer = { { "q", "quit" }, { "Enter", "select" } },
})
```
---
### `maki.ui.set_status_hint()` {#maki-ui-set_status_hint}
```lua
maki.ui.set_status_hint({spans})
```
Shows key hints in the status bar for your plugin. Each hint is a {key, label} pair. Pass nil to clear your plugin's hints. Only your own hints are affected, other plugins keep theirs.
**Parameters:**
- `{spans}` (`table|nil`) Sequence of {key, label} pairs, e.g. `{{"q", "quit"}, {"j", "down"}}`. Pass nil to remove the plugin's hints.
**Example:**
```lua
maki.ui.set_status_hint({ {"q", "quit"}, {"j", "down"} })
-- later, clear them:
maki.ui.set_status_hint(nil)
```
---
### `maki.ui.set_window_title()` {#maki-ui-set_window_title}
```lua
maki.ui.set_window_title({title})
```
Sets the terminal emulator's window title. Pass an empty string to
clear it.
The title passes through tmux, GNU screen, and zellij untouched, and
control characters are stripped, so model text cannot inject escape
sequences into the terminal. On exit maki hands the title back to the
shell, on terminals that support the title stack.
**Parameters:**
- `{title}` (`string`) New window title, e.g. `"● 3/5 tests"`.
**Example:**
```lua
maki.ui.set_window_title("maki: " .. session_name)
-- Give the title back to the shell:
maki.ui.set_window_title("")
```
## maki.ui.Win {#maki-ui-Win}
Handle to a floating or split window. You get one from
`maki.ui.open_win()`. Use `recv()` in a loop to handle keyboard
input, and call `close()` when done.
Fields: `width`, `height` (initial content dimensions in columns/rows),
`visible` (current visibility).
```lua
local win = maki.ui.open_win(buf, { title = "Demo" })
while true do
local ev = win:recv()
if not ev or ev.key == "q" then break end
end
win:close()
```
---
### `Win:recv()` {#Win-recv}
```lua
Win:recv({timeout_ms?})
```
Waits for the next event from this window. Call this in a loop to build an interactive UI. Returns nil once the window is closed or the channel disconnects. Pass {timeout_ms} to also get `{type="timeout"}` events so your plugin can animate while idle.
Event tables by type:
- `{type="key", key}` -- keypress. Key is a string like "q", "j", or "esc".
- `{type="resize", width, height}` -- terminal was resized.
- `{type="paste", text}` -- bracketed paste.
- `{type="close"}` -- window was closed externally.
- `{type="timeout"}` -- no event arrived within {timeout_ms}.
**Parameters:**
- `{timeout_ms?}` (`integer`) Max milliseconds to wait before a timeout event is returned.
**Returns:** (`table|nil`) Event table, or nil if the window has closed.
**Example:**
```lua
while true do
local ev = win:recv()
if not ev or ev.key == "q" then break end
if ev.type == "key" and ev.key == "j" then
-- move cursor down
end
end
win:close()
```
---
### `Win:set_config()` {#Win-set_config}
```lua
Win:set_config({opts})
```
Updates the window layout on the fly. Only the fields you include in
{opts} are changed, everything else stays the same.
**Parameters:**
- `{opts}` (`table`) Partial float config. Accepted fields:
- `title` (`string`) border title text.
- `title_pos` (`string`) title alignment, "left", "center", or "right".
- `footer` (`table`) key-hint pairs `{{key, label}, ...}` shown in the bottom border.
- `border` (`string`) "rounded", "single", "double", or "none".
- `anchor` (`string`) corner origin, "NW", "NE", "SW", or "SE".
- `width` (`integer|string`) new width; integer or "N%".
- `height` (`integer|string`) new height; integer or "N%".
- `zindex` (`integer`) stacking order.
- `cursor_line` (`boolean`) highlight the focused row.
- `reserved_top` (`integer`) rows reserved at the top of the content area.
- `split` (`string`) edge docking, "above", "below", "left", "right", "panel", or "".
- `order` (`integer`) paint order among split windows.
- `needs_input` (`boolean`) whether the window means the session needs user input.
**Example:**
```lua
win:set_config({ title = "Updated!", width = "80%" })
```
---
### `Win:set_cursor()` {#Win-set_cursor}
```lua
Win:set_cursor({row})
```
Moves the highlighted cursor line to {row} (1-indexed). Only has a
visible effect when the window was opened with `cursor_line = true`.
**Parameters:**
- `{row}` (`integer`) Target row, 1-indexed.
**Example:**
```lua
win:set_cursor(3) -- highlight the third line
```
---
### `Win:close()` {#Win-close}
```lua
Win:close()
```
Closes the window and frees its resources. Safe to call more than
once. The window also closes automatically when the handle is
garbage collected.
**Example:**
```lua
win:close()
```
---
### `Win:is_open()` {#Win-is_open}
```lua
Win:is_open()
```
Returns true if the window is still alive (not closed). Useful for
checking before sending commands.
**Returns:** (`boolean`) true if open.
**Example:**
```lua
if win:is_open() then
win:set_config({ title = "still here" })
end
```
---
### `Win:show()` {#Win-show}
```lua
Win:show()
```
Makes the window visible again after it was hidden with `hide()`.
**Example:**
```lua
win:show()
```
---
### `Win:hide()` {#Win-hide}
```lua
Win:hide()
```
Hides the window without closing it. The window keeps its state
and buffer contents. Call `show()` to bring it back.
**Example:**
```lua
win:hide()
-- do some work...
win:show()
```
---
### `Win:is_visible()` {#Win-is_visible}
```lua
Win:is_visible()
```
Returns true if the window is both open and visible (not hidden).
**Returns:** (`boolean`) true if visible.
## maki.ui.Buf {#maki-ui-Buf}
A content buffer that holds styled lines of text. Create one with
`maki.ui.buf()` and pass it to `maki.ui.open_win()` to show it in
a floating or split window.
```lua
local buf = maki.ui.buf()
buf:line("hello")
buf:line({ { "world", "bold" } })
```
---
### `Buf:line()` {#Buf-line}
```lua
Buf:line({line})
```
Appends a single line to the end of the buffer. You can pass a
plain string for unstyled text, or a table of `{text, style?}` spans
for rich content. Style can be a named string like "bold" or
"keyword", or an inline table `{fg?, bg?, bold?, italic?, underline?, dim?, strikethrough?, reversed?}`.
Colors accept "#rrggbb", a terminal color name like "blue" or "light-gray",
or a palette index as a string like "4". Names must be spelled exactly,
hyphens included. Named and indexed colors are left for the terminal to
resolve, so they follow the user's palette.
**Parameters:**
- `{line}` (`string|table`) Plain string, or a sequence of spans: `{ {text, style?}, ... }`.
**Example:**
```lua
buf:line("plain text")
buf:line({ { "ERROR", { fg = "#ff0000", bold = true } }, { " something broke" } })
```
---
### `Buf:lines()` {#Buf-lines}
```lua
Buf:lines({lines})
```
Appends several lines at once. Each entry uses the same format as
`buf:line()`, so you can mix plain strings and styled spans.
**Parameters:**
- `{lines}` (`table`) Sequence of line values, each the same format accepted by `buf:line`.
**Example:**
```lua
buf:lines({
"first line",
{ { "styled ", "bold" }, { "second line" } },
"third line",
})
```
---
### `Buf:set_lines()` {#Buf-set_lines}
```lua
Buf:set_lines({lines})
```
Replaces every line in the buffer with {lines}. Use this when you
want to redraw the whole buffer, for example after the user toggles
a view.
**Parameters:**
- `{lines}` (`table`) Sequence of line values, each the same format accepted by `buf:line`.
**Example:**
```lua
buf:set_lines({ "new content", "replaces everything" })
```
---
### `Buf:len()` {#Buf-len}
```lua
Buf:len()
```
Returns how many lines the buffer currently holds.
**Returns:** (`integer`) Line count.
**Example:**
```lua
if buf:len() == 0 then
buf:line("(empty)")
end
```
---
### `Buf:get_lines()` {#Buf-get_lines}
```lua
Buf:get_lines()
```
Returns all lines in the buffer as a Lua table. Each line is a
sequence of `{text, style?}` spans, the same format `buf:line()`
accepts. Useful for reading back content, copying it to another
buffer, or round-tripping through `set_lines()`.
**Returns:** (`table`) Sequence of lines.
**Example:**
```lua
local lines = buf:get_lines()
buf:set_lines(lines) -- round-trip
```
---
### `Buf:on()` {#Buf-on}
```lua
Buf:on({event}, {callback})
```
Registers an event handler on the buffer.
Supported events:
- "click": fires when the user clicks a line. The handler receives
a click-event table and may yield or mutate the buffer.
- "change": fires synchronously after every mutation (`line`,
`lines`, `set_lines`). Must not yield.
Calling `on()` again for the same event replaces the previous handler.
**Parameters:**
- `{event}` (`string`) Event name: "click" or "change".
- `{callback}` (`function`) Handler function. For "click", receives a click-event table. For "change", receives no arguments.
**Example:**
```lua
buf:on("click", function(ev)
maki.ui.flash("Clicked row " .. ev.row)
end)
```
---
### `Buf:click()` {#Buf-click}
```lua
Buf:click({ev})
```
Programmatically fires the buffer's click handler with event {ev}.
Does nothing if no click handler is registered. Useful for testing
or simulating user interaction from code.
**Parameters:**
- `{ev}` (`table`) Click event table passed to the handler.
**Example:**
```lua
buf:click({ row = 1 })
```
---
### `Buf:blit()` {#Buf-blit}
```lua
Buf:blit({fb}, {width}, {height}, {opts?})
```
Replaces the whole buffer with a pixel frame drawn as `"▀"` cells.
Each cell's foreground is the top pixel and its background the
bottom one, so one text line fits two pixel rows. When {height} is
odd the last line leaves its background unset and the terminal
default shows through.
{fb} is a Luau `buffer` of raw pixel bytes in row-major order,
top-left origin. Its size must be exactly
`width * height * bytes_per_pixel` for the chosen format, otherwise
the call throws. A mismatch usually means a wrong width or format,
and an early error beats hunting down a garbled frame.
Formats: "rgb" is the default at 3 bytes per pixel. "rgba" and
"bgra" take 4 bytes per pixel and ignore the 4th byte. "bgra" is
what a little-endian `uint32` holding `0xRRGGBB` looks like in
memory, the layout doomgeneric uses for its framebuffer.
`char` swaps the `"▀"` glyph for another one column wide string,
e.g. `"█"` when only the foreground color should show. The
foreground still comes from the top pixel and the background from
the bottom one, whatever the glyph.
**Parameters:**
- `{fb}` (`buffer`) Raw pixel bytes.
- `{width}` (`integer`) Frame width in pixels, > 0.
- `{height}` (`integer`) Frame height in pixels, > 0.
- `{opts?}` (`table|nil`) Options: `format` = "rgb"|"rgba"|"bgra", `char` = one column wide string.
**Example:**
```lua
local fb = buffer.create(160 * 100 * 3)
buffer.writeu8(fb, (y * 160 + x) * 3, 255) -- red channel
buf:blit(fb, 160, 100)
buf:blit(fb32, 160, 100, { format = "bgra", char = "█" })
```
## maki.uv {#maki-uv}
System and environment utilities, modelled after `vim.uv`.
Provides access to the working directory, home directory, and environment
variables. None of these functions throw.
Filesystem location queries (`cwd`, `os_homedir`) need `fs_read`, while
`os_getenv` reads the process environment, where secrets live, so it needs
`env`.
```lua
local home = maki.uv.os_homedir()
```
---
### `maki.uv.cwd()` {#maki-uv-cwd}
```lua
maki.uv.cwd()
```
Return the current working directory as an absolute path. Like `vim.uv.cwd`.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Returns:** (`string?`) Current working directory, or nil if it cannot be determined.
**Example:**
```lua
local cwd = maki.uv.cwd()
if cwd then print("working in: " .. cwd) end
```
---
### `maki.uv.os_homedir()` {#maki-uv-os_homedir}
```lua
maki.uv.os_homedir()
```
Return the current user's home directory. Like `vim.uv.os_homedir`.
Requires the `fs_read` [plugin permission](#plugin-permissions).
**Returns:** (`string?`) Home directory path, or nil if it cannot be determined.
**Example:**
```lua
local home = maki.uv.os_homedir() -- e.g. "/home/user"
```
---
### `maki.uv.os_getenv()` {#maki-uv-os_getenv}
```lua
maki.uv.os_getenv({name})
```
Look up the environment variable {name}. Like `vim.uv.os_getenv`.
Returns nil when the variable is not set.
Requires the `env` [plugin permission](#plugin-permissions).
**Parameters:**
- `{name}` (`string`) Name of the environment variable.
**Returns:** (`string?`) Variable value, or nil if not set.
**Example:**
```lua
local editor = maki.uv.os_getenv("EDITOR") or "vi"
```
## maki.yaml {#maki-yaml}
YAML encoding and decoding. Works the same way as `maki.json`,
but for YAML formatted strings.
```lua
local t = maki.yaml.decode("greeting: hello")
print(t.greeting)
```
---
### `maki.yaml.encode()` {#maki-yaml-encode}
```lua
maki.yaml.encode({value})
```
Turn a Lua value into a YAML string. Most Lua types work, but
circular references will return an error.
**Parameters:**
- `{value}` (`any`) Lua value to encode.
**Returns:** (`string?`, `string?`) YAML string, or nil plus an error.
**Example:**
```lua
local s, err = maki.yaml.encode({ name = "maki", tags = { "ai", "agent" } })
print(s)
```
---
### `maki.yaml.decode()` {#maki-yaml-decode}
```lua
maki.yaml.decode({str})
```
Parse a YAML string into a Lua value. Mappings become tables and
sequences become 1-indexed arrays.
**Parameters:**
- `{str}` (`string`) YAML string to decode.
**Returns:** (`any?`, `string?`) Decoded value, or nil plus an error.
**Example:**
```lua
local t, err = maki.yaml.decode("name: maki\nversion: 1")
print(t.name) -- maki
```
## Shared helper modules
These ship inside maki; `require` them from any plugin. Small modules are
shown as full source, larger ones as their public interface.
### `require("maki.color")`
```lua
local M = {}
function M.lerp(from, to, t)
local fr, fg, fb = from:match("^#(%x%x)(%x%x)(%x%x)$")
local tr, tg, tb = to:match("^#(%x%x)(%x%x)(%x%x)$")
if not fr or not tr then
return nil
end
fr, fg, fb = tonumber(fr, 16), tonumber(fg, 16), tonumber(fb, 16)
tr, tg, tb = tonumber(tr, 16), tonumber(tg, 16), tonumber(tb, 16)
local r = math.floor(fr + (tr - fr) * t + 0.5)
local g = math.floor(fg + (tg - fg) * t + 0.5)
local b = math.floor(fb + (tb - fb) * t + 0.5)
return string.format("#%02x%02x%02x", r, g, b)
end
function M.dim(color, factor)
local bg = maki.ui.theme_color("background")
return bg and M.lerp(color, bg, factor)
end
return M
```
### `require("maki.dir_listing")`
```lua
-- Shared directory listing for index and list plugins.
-- Lists entries, filters instruction files, sorts dirs before files, and
-- renders the listing so every caller shows a directory the same way.
function M.list(path, ctx)
function M.view(text, ctx)
```
### `require("maki.fuzzy_replace")`
```lua
M.NO_MATCH = "old_string not found in file"
M.MULTIPLE_MATCHES = "old_string matches multiple locations; add surrounding context to make it unique"
M.EMPTY_OLD_STRING = "old_string must not be empty"
-- Replace {old_string} with {new_string} in {content}, tolerating small
-- whitespace and indentation drift. Returns the new content, or nil plus
-- one of the error constants above.
function M.replace(content, old_string, new_string, replace_all)
```
### `require("maki.list_picker")`
```lua
-- Draws the filter query and its blank spacer into {lines}, pins that height on
-- {win} and returns it, which is also the first scrollable line. Drawing and
-- pinning belong together: a query that wraps, or one pasted with a newline,
-- makes the header taller than a picker would guess, and a reserved_top guessed
-- elsewhere then mis-scrolls the list.
function ListPicker.render_header(win, lines, input, prefix, inner)
-- Open a fuzzy-filter picker in a floating window and block until the user
-- decides. {items} is a list of strings or { label, detail? } tables. {opts}:
-- title, footer, cursor (initial index), submit_keys (extra submit keys
-- besides enter), action_keys (keys that close the picker and report
-- themselves, like { "R" } for a refresh binding. Use uppercase keys, since
-- lowercase ones keep feeding the filter). Returns
-- { type = "choice"|"delete", index }, { type = "key", key, index? } or
-- { type = "close" }.
function ListPicker.open(items, opts)
ListPicker.split_words = split_words
ListPicker.matches = matches
ListPicker.highlight_spans = highlight_spans
```
### `require("maki.output_limits")`
```lua
-- Shared per-tool output limit options, so the tools that support them
-- cannot drift apart.
local DEFAULT_MAX_OUTPUT_LINES = 2000
local DEFAULT_MAX_OUTPUT_BYTES = 50 * 1024
local DEFAULT_MAX_LINE_BYTES = 500
local M = {}
M.DEFAULT_MAX_LINE_BYTES = DEFAULT_MAX_LINE_BYTES
M.specs = {
max_output_lines = { type = "integer", desc = "Override `agent.max_output_lines` for this tool." },
max_output_bytes = { type = "integer", desc = "Override `agent.max_output_bytes` for this tool." },
}
function M.extend(spec)
for name, s in pairs(M.specs) do
spec[name] = s
end
return spec
end
--- Returns max_lines, max_bytes: tool override when set, agent-wide otherwise.
function M.resolve(opts, ctx)
return opts.max_output_lines or ctx:config("max_output_lines", DEFAULT_MAX_OUTPUT_LINES),
opts.max_output_bytes or ctx:config("max_output_bytes", DEFAULT_MAX_OUTPUT_BYTES)
end
return M
```
### `require("maki.partial")`
```lua
-- When a tool is cut short, it still hands back what it printed. The marker
-- tells the model that output is real but unfinished. One home for the
-- wording and the painting, so every tool says it the same way.
--- Close {view} on the marker and build the tool reply. {out} is everything
--- the tool streamed, already truncated; empty means the view still shows a
--- placeholder to drop. {reason} is a cancel-hook reason ("cancelled" |
--- "timeout").
function M.cut(view, out, reason, timeout_secs)
```
### `require("maki.scroll")`
```lua
-- Relative scrolling on top of maki.fn.winsaveview / winrestview.
-- Positive {delta} scrolls down, negative up. Returns (true, nil) or (nil, err).
local function scroll(delta)
local view, err = maki.fn.winsaveview()
if not view then
return nil, err
end
return maki.fn.winrestview({ topline = view.topline + delta })
end
return scroll
```
### `require("maki.shorten_path")`
```lua
local function normalize_sep(s)
return s:gsub("\\", "/")
end
local function shorten_path(path)
local p = normalize_sep(path)
local cwd = maki.uv.cwd()
if cwd then
cwd = normalize_sep(cwd)
if p:sub(1, #cwd + 1) == cwd .. "/" then
local rel = p:sub(#cwd + 2)
return rel == "" and "." or rel
end
end
local home = maki.uv.os_homedir()
if home then
home = normalize_sep(home)
if p:sub(1, #home + 1) == home .. "/" then
local rel = p:sub(#home + 2)
return rel == "" and "~" or "~/" .. rel
end
end
return path
end
return shorten_path
```
### `require("maki.test_helpers")`
```lua
-- Shared test helpers for Lua plugin specs.
--
-- Provides a lightweight test harness: `case` wraps each block in pcall so a
-- single failure does not abort the rest of the suite. Failures are collected
-- and surfaced by `report()` at the end.
function M.case(name, fn)
function M.eq(actual, expected, msg)
function M.has(s, substr, msg)
function M.mktmpdir(prefix)
function M.rmtree(dir)
function M.report()
```
### `require("maki.text_input")`
```lua
-- TextInput: multi-line editable buffer with a byte-offset cursor.
--
-- Invariants enforced everywhere:
-- * `line` is 1-based and indexes a line that always exists.
-- * `col` is a byte offset inside `lines[line]`, always on a UTF-8 codepoint
-- boundary, so `lines[line]:sub(1, col)` is a complete UTF-8 prefix.
-- * No line ever contains a literal newline; newlines split into rows.
--
-- Parents OWN their keys. `handle_key` returns one of R.IGNORED / R.MOVED /
-- R.CHANGED. Parent dispatchers must filter their own keys (esc, ctrl+c,
-- submit keys, etc.) BEFORE forwarding, because `handle_key` claims any key
-- it can interpret. `ctrl+a` is bound to move-home; if a parent wants it for
-- "select all" it must intercept first.
--
-- IGNORED is returned when the buffer literally cannot act (backspace at
-- (1, 0), right at end of buffer, etc.). Parents can use that signal to fall
-- through to their own logic.
--
-- Parity cases live in plugins/lib/tests/spec.lua (TRACE_CASES). Add one
-- whenever you change handle_key semantics.
TextInput.Result = R
function TextInput.new()
function TextInput:value()
function TextInput:is_empty()
function TextInput:line_count()
function TextInput:clear()
-- Returns the codepoint right before the cursor as a string, or nil at the
-- start of a line. Lets callers peek backwards (e.g. "is the previous char
-- a backslash?") without touching internal indices.
function TextInput:char_before_cursor()
function TextInput:insert_text(text)
function TextInput:insert_char(c)
function TextInput:insert_space()
function TextInput:split_line()
function TextInput:remove_char()
function TextInput:delete_char()
function TextInput:remove_word_before()
function TextInput:delete_word_after()
function TextInput:kill_to_end_of_line()
function TextInput:move_left()
function TextInput:move_right()
function TextInput:move_up()
function TextInput:move_down()
function TextInput:move_home()
function TextInput:move_end()
function TextInput:move_word_left()
function TextInput:move_word_right()
function TextInput:handle_key(key)
-- Wrap lines to {width} with {prefix} before the first row. Returns
-- { lines = styled lines, cursor_row = 1-based row holding the cursor }.
function TextInput:render(prefix, prefix_width, width)
```
### `require("maki.toast")`
```lua
-- Corner toast notifications built on floating windows. `maki.ui.flash` gives
-- you one line in the status area. A toast stays up long enough to read, can
-- carry a title, and stacks under the toasts already on screen.
-- Show {text} as a toast, up to 5 lines of it. {opts}: title (string),
-- timeout_secs (integer, default 4). Returns right away and the toast
-- dismisses itself when the time is up.
function Toast.show(text, opts)
```
### `require("maki.tool_view")`
```lua
-- The shared truncate/expand body that tool plugins render through.
--
-- Click handlers get `ev.row`, a 1-based line in this buf; 0 means the
-- click landed outside it (the header). The handler lives on the buf
-- itself, so any wrapper of the same buf (a batch child's foreign handle)
-- reaches the same toggle. Expansion is never stored: the UI records
-- clicked rows and replays them through `restore` in order, so `toggle`
-- stays a pure flag flip + re-render, deterministic across replays.
-- Async highlighting goes through `maki.async.run`; during restore the
-- runtime runs those tasks inline before snapshotting.
-- Right aligned, so the content column stays put when a number gains a digit.
-- Formats the number alone, callers add their own separator.
function ToolView.line_nr_fmt(max_line_nr)
-- opts: max_lines (default 80) shown while collapsed, keep "head"|"tail"
-- (default "tail"), max_expand_lines (default 2000) kept for expansion,
-- max_line_bytes (optional) per-line byte cap applied at render time.
function ToolView.new(buf, opts)
function ToolView:set_header(lines)
function ToolView:clear()
function ToolView:append(line)
function ToolView:append_text(text)
-- Append {content} with line numbers, then syntax-highlight it for {ext}
-- asynchronously. Returns false when {content} is empty.
function ToolView:set_highlight(content, ext)
-- Content rows on screen, for callers with their own per-row click targets. A
-- single hidden line is drawn as itself instead of a notice, so it counts as
-- content too. Rows line up with `all_lines` under keep = "head"; keep = "tail"
-- prints its notice first and shifts them.
function ToolView:visible_count()
function ToolView:toggle()
function ToolView:flush()
function ToolView:update_line(all_idx, line)
-- Call once after the last append so the collapsed notice renders.
function ToolView:finish()
function ToolView.restore_lines(lines, opts)
-- Rebuild a collapsed view from a tool's saved llm_output, click-to-toggle
-- wired. For `restore` hooks.
function ToolView.restore(output, opts)
-- Same, for tools whose live output goes through markdown (`format =
-- "markdown"`); {opts.width} is the wrap width. Errors stay plain, as they do
-- live.
function ToolView.restore_markdown(output, is_error, opts)
```
### `require("maki.truncate")`
```lua
local function truncate(text, max_lines, max_bytes)
if #text <= max_bytes then
local n = 0
for _ in text:gmatch("\n") do
n = n + 1
end
if n + 1 <= max_lines then
return text
end
end
local out = {}
local bytes = 0
local lines = 0
for line in text:gmatch("([^\n]*)\n?") do
lines = lines + 1
if lines > max_lines then
break
end
local new_bytes = bytes + #line + 1
if new_bytes > max_bytes then
break
end
out[#out + 1] = line
bytes = new_bytes
end
local result = table.concat(out, "\n")
if #result < #text then
result = result .. "\n\n[truncated " .. (#text - #result) .. " bytes]"
end
return result
end
return truncate
```
---
# CLI
`maki` without a subcommand starts the TUI. Subcommands cover auth, models, MCP OAuth, updates, and a few debug helpers. Many flags only apply to one of three run paths: **TUI**, one-shot **`--print`**, or **SDK** (`--print --input-format stream-json`).
```bash
maki [OPTIONS] [PROMPT]
maki
```
If you pass a prompt (or pipe stdin) without `--print`, the TUI still opens and that text is the first message. With `--print`, Maki runs non-interactively and exits when done.
## Flags by run path
| Flag | TUI | `--print` | SDK (`stream-json`) |
|------|-----|-----------|---------------------|
| `-m` / `--model` | yes | yes | yes |
| `--yolo` | yes | yes | yes (or `--permission-mode bypassPermissions`) |
| `--no-plugins` / `--no-commands` / `--no-jit` | yes | yes | yes |
| `--allowed-tools` / `--disallowed-tools` | yes | yes | yes |
| `-c` / `--continue`, `-s` / `--session` | yes | no (always new session) | yes |
| `--exit-on-done` | yes | n/a (always exits) | n/a |
| `--image` | no (use Ctrl+V paste) | yes | via wire protocol |
| `--verbose`, `--output-format` | no | yes | stream only |
| `--system-prompt`, `--append-system-prompt` | no | no | yes |
| `--max-turns`, `--session-id`, `--fork-session` | no | no | yes |
| `--permission-mode` | no | no | yes |
| `--include-partial-messages` | no | no | yes |
### Shared flags (detail)
| Flag | Description |
|------|-------------|
| `-p`, `--print` | Non-interactive run. See [Headless Mode](/docs/headless/) |
| `--image ` | Attach an image in `--print` mode (repeatable). Paths must be png, jpeg, gif, or webp |
| `-m`, `--model ` | Model as `provider/model-id`. Fallback: last used → `provider.default_model` in config → auto-detect from available providers |
| `--verbose` | Full turn-by-turn messages in `--print` output |
| `-c`, `--continue` | Resume the most recent session in this directory (TUI / SDK only) |
| `-s`, `--session` / `--resume ` | Resume a specific session (TUI / SDK only) |
| `--output-format ` | Output shape for `--print` (default `text`) |
| `--input-format ` | With `--print`, `stream-json` enters SDK mode |
| `--no-commands` | Skip custom commands from `.maki/commands`, `.claude/commands`, etc. |
| `--no-plugins` | Skip user `init.lua` (global and project); keep the Lua host and builtin plugins so tools and the default keymap still load |
| `--no-jit` | Run plugin Lua on the interpreter with full debug info |
| `--yolo` | Skip permission prompts on gated tools (alias: `--dangerously-skip-permissions`). Deny rules still apply |
| `--trust` | Load the project's `.maki` config for this run without asking, recording no decision. See [Folder Trust](/docs/folder-trust/#containers-and-ci) |
| `--exit-on-done` | Exit when the agent finishes (TUI automation wrappers) |
| `--allowed-tools ` | Comma-separated allow list (PascalCase or snake_case) |
| `--disallowed-tools ` | Comma-separated deny list |
| `--session-id ` | Session id for SDK mode |
| `--fork-session` | Load a session's history under a new id (SDK) |
| `--max-turns ` | Cap agent turns (SDK) |
| `--system-prompt ` | Replace the system prompt (SDK only) |
| `--append-system-prompt ` | Append to the built-in system prompt (SDK only) |
| `--permission-mode ` | SDK: `default`, `acceptEdits`, `plan`, or `bypassPermissions` |
| `--include-partial-messages` | Stream partial deltas in SDK mode |
### Tool name lists
`--allowed-tools` / `--disallowed-tools` accept Claude Code PascalCase (`Read,Edit,Bash`) or snake_case (`read,edit,bash`). Maki lowercases PascalCase to snake_case and checks the result against the built-in tool names, so `CodeExecution` works but `MultiEdit` errors: it normalizes to `multi_edit`, and the tool is called `multiedit`. Write `multiedit` or `edit_lines` as-is. Unknown names error out with the list of valid names. The edit plugin's sub-tools (`multiedit`, `edit_lines`, `insert_lines`) are valid names here even when disabled. Listing a disabled tool has no effect until you enable it in config.
### Permission modes (SDK)
| Mode | Effect |
|------|--------|
| `default` | Normal permission prompts |
| `acceptEdits` | Accepted for Claude Code compatibility; currently same as `default` |
| `plan` | Agent mode plan with plan file `./plan.md` under cwd |
| `bypassPermissions` | Same as `--yolo` for the SDK path |
If both `--yolo` and `--permission-mode` are set, the explicit mode wins. Unknown mode names warn and fall back to `default`.
Several other Claude Code flags are accepted and ignored so existing scripts keep parsing. Maki prints a warning when you pass one of them.
## Subcommands
### `maki auth`
```bash
maki auth login [provider] # interactive picker if omitted
maki auth logout
maki auth status
```
`login` stores credentials under the state directory and can write plan / base URL choices into `providers.toml` (see [Configuration](/docs/configuration/#directory-layout) for the platform path). OpenAI and Copilot have dedicated flows; other providers prompt for a key (and a plan when the provider has more than one). Custom providers can be created from the interactive picker.
`status` shows each provider as configured (key on disk), env-only, or missing.
### `maki models`
```bash
maki models
maki models --refresh # refetch the models.dev catalog
```
One spec per line, warnings on stderr. Built-in and script providers are listed live, catalog-backed providers from the models.dev cache, which expires after 24 hours and supplies model pricing and context windows. `--refresh` refetches it. When the refetch fails, the cached catalog stays in place, the list still prints, and the command exits non-zero.
### `maki session`
```bash
maki session list # sessions for the current directory
maki session list --global # sessions from all projects
maki session delete # asks first, -f skips
```
Prints stored sessions as a table (id, title, project directory with `$HOME` collapsed to `~`, last update as a relative age), newest first. A listed id works with `maki --session ` to resume it. `delete` removes the session log along with its archives and index entries, and asks for confirmation first unless you pass `-f` / `--force`; without a terminal to ask on it refuses outright. A maki that already has the session open will not notice the delete and will lose the rest of that conversation, so close it first. Inside the TUI the same data lives behind `/sessions` (`Ctrl+P`), where `Ctrl+D` deletes.
### `maki mcp`
```bash
maki mcp auth # OAuth for an HTTP MCP server
maki mcp logout # drop stored tokens
```
Server names come from your [MCP config](/docs/mcp/). On a machine without a browser, `auth` prints a URL you open elsewhere and paste back.
### `maki update` / `maki rollback`
```bash
maki update # install latest release
maki update -y # skip confirmation
maki update --no-color
maki rollback # previous version
```
Uses the same install locations as the install scripts.
### `maki acp`
```bash
maki acp
maki acp -m anthropic/claude-sonnet-4-6
maki acp --yolo
maki --no-jit acp
```
Starts an [ACP](/docs/acp/) server on stdio for editors like Zed. Subcommand flags are only `-m` / `--model` and `--yolo`. Global flags like `--no-jit` must come before the subcommand.
### `maki index`
```bash
maki index path/to/file.rs
```
Runs the `index` tool on a file and prints the skeleton, so you can see what the agent will get before a session. Builtin plugins always load here; `--no-plugins` only skips user `init.lua`.
### `maki prompt`
```bash
maki prompt # rendered system prompt (default: system variant)
maki prompt research
maki prompt general
maki prompt --plan # system prompt + plan-mode reminder (system only)
maki prompt --tools # tool definitions as JSON
maki prompt --tools --names # tool names only, one per line
```
Debug helper for inspecting the prompt and tool surface the agent sees. `--plan` is rejected on non-system variants.
### `maki migrate`
```bash
maki migrate xdg
```
Moves data from `~/.maki/` into platform directories. Safe to re-run. See [Configuration](/docs/configuration/#directory-layout).
### `maki trust`
```bash
maki trust add [PATH]
maki trust add [PATH] --yes
maki trust remove [PATH]
maki trust list
```
Records whether a folder's `.maki` configuration may load. `PATH` defaults to
the current directory, `--yes` skips the confirmation, and `list` shows trusted
and rejected folders. See [Folder Trust](/docs/folder-trust/) for what is gated
and for the `--trust` flag that grants trust for a single run.
## Everyday examples
```bash
# TUI on a project
cd ~/code/my-app && maki
# One-shot with YOLO and a model pin
maki -p --yolo -m anthropic/claude-sonnet-4-6 "summarize the architecture"
# Resume yesterday's session
maki --continue
# List models, then log in
maki models
maki auth login
# Inspect tools without starting a session
maki prompt --tools --names
```
For JSON / stream-json output, stdin prompts, and SDK wire mode, see [Headless Mode](/docs/headless/).
---
# Lua packages
A Lua package lets you add tools, commands, keybindings, and event handlers
without copying its code into `init.lua`. Maki can load a package that you put
on disk, or it can install one from a Git repository and lock it to one commit.
A package directory holds sorted `plugin/*.lua` entry files, modules at
`lua/.lua` or `lua//init.lua`, and a `plugin.toml` manifest.
The entry files share one environment and use the API the
[plugin guide](/docs/plugins/) describes.
## Install from Git
Declare managed packages in the global `init.lua`, normally
`~/.config/maki/init.lua`:
```lua
maki.pack.add({
"https://github.com/example/maki-goal",
{
src = "https://github.com/example/maki-review",
version = "v1.2.0",
},
})
```
Each entry is a source string, or a table with `src`, `version`, `name`, and
`data`. Maki derives the directory and owner name from `src`, and `name`
overrides it when two sources end in the same repository name.
Maki shows all new packages in one install prompt, on the terminal, before the
UI starts. It writes the selected Git commit to `pack-lock.json` in your config
directory. Commit this file if you want another machine to install the same
revisions.
Every project shares one lockfile and package directory, so a project
`.maki/init.lua` cannot add packages. It can still read state with
`maki.pack.get` and activate a package with `maki.packadd`.
Maki refuses a package name that matches a builtin plugin or a package you
placed by hand, and reports the conflict at startup.
Set `confirm = false` only when the package source is already trusted and Maki
must run without a terminal:
```lua
maki.pack.add({ "https://github.com/example/maki-goal" }, {
confirm = false,
})
```
This option skips the install prompt. It does not approve package permissions.
Maki rejects an HTTP source carrying a username, password, or token, since Git
and the lockfile would store it. Use a credential helper or an SSH agent.
Set `load = false` to install a package without loading it at startup. Set
`load` to a function when the package needs a custom entry point:
```lua
maki.pack.add({
{
src = "https://github.com/example/maki-review",
data = { module = "review" },
},
}, {
load = function(package)
require(package.spec.data.module).setup()
end,
})
```
The function runs as the package owner. It receives the package `spec` and its
installed `path`. The `data` field can contain any Lua value.
See [`maki.pack.add`](/docs/lua-api/#maki-pack-add) and
[`maki.pack.get`](/docs/lua-api/#maki-pack-get) for the full signatures.
## Pinned revisions
A lockfile entry wins over `version`: once Maki records a commit, it installs
that commit everywhere, and a later `version` in `init.lua` changes nothing. To
move a package, delete its entry from `pack-lock.json` and
start Maki again. Maki resolves `version` and records the commit it picked.
A changed `src` makes the recorded revision meaningless, so Maki installs the
new source and records it. That is also a new trust decision, so the install
and permission prompts come back.
Removing a `maki.pack.add` entry stops the package from loading. Its lockfile
entry and its checkout stay on disk until you delete them.
## Update packages
Run `/packupdate` to update every installed package that the global config
still declares. Pass one package name to update only that package:
```text
/packupdate maki-review
```
Maki fetches the source and shows the old and proposed commits. It compares the
proposed permission request with the permissions already approved for that
package and source. Accepting the review applies the revision and approves the
permissions it showed. Declining leaves the installed revision alone.
Use `/packupdate!` to skip the update review. The bang does not cover
permissions, so Maki still asks about a new one when it loads the updated
package.
Pass `++lockfile` to restore the commit already recorded in the lockfile
instead of resolving the declared version:
```text
/packupdate ++lockfile maki-review
```
Project config and packages cannot update global package state.
## Remove packages
First remove the package declaration from the global `init.lua` and reload.
Then remove the inactive package:
```text
/packdel maki-review
```
`/packdel ++all` removes every installed package that is no longer declared.
Maki shows what it is about to remove and waits for you to accept. A package
that is still active, here or in another Maki process, is refused. The package
approval goes away once its files are gone.
Use `/packdel!` to skip that review. The refusals still apply.
## Package permissions
A managed package can ask for guarded APIs in `plugin.toml`:
```toml
[permissions]
fs_read = true
fs_write = true
net = true
run = true
env = true
```
The manifest states what the package wants, and Maki asks about new permissions
in a separate prompt. A package with no `plugin.toml` asks for nothing, and
every guarded call it makes fails. The
[permission list](/docs/lua-api/#plugin-permissions) covers what each name
gates.
An approval applies only to the same package name and source. Maki keeps
approvals in `/site/pack-approvals.json`, where `` is the
data directory from the
[directory layout](/docs/configuration/#directory-layout). Approvals describe
trust on this machine and must not be committed with `pack-lock.json`.
Only the interactive UI can ask. `--print`, SDK mode, the ACP server, and the
other subcommands never prompt, so a package waiting for a decision comes back
as a startup warning instead of loading.
## Install by hand
Clone a package into a Neovim-style package directory:
```text
/site/pack//start//
/site/pack//opt//
```
Pick any `` name except `core`, which Maki reserves for its own installs
and never scans.
A `start` package loads at startup. An `opt` package stays installed until
something activates it.
Placing a package by hand means you trust its code the way you trust your own
`init.lua`. Maki grants it exactly the permissions its `plugin.toml` requests,
with no approval step.
## Activate an installed package
An `opt` directory, or a managed package declared with `load = false`, starts
with `maki.packadd`. Call it from `init.lua` or from another package:
```lua
maki.packadd("my-package")
```
Maki loads the named package after the calling Lua task returns. A package
that `maki.packadd` activates can activate another one in turn. Maki reports a
name that no installed package matches, and refuses a package that
`plugins..enabled = false` disabled.
## Managed checkouts
Maki restores a missing checkout only when the global config still declares the
package and its source matches the lockfile entry. Installs and approval writes
share one file lock, so two Maki processes cannot interleave them. The kernel
releases that lock when the process exits, so a crash leaves nothing to clean
up.
Each revision gets its own directory under
`/site/pack/core///`. At startup Maki deletes stale
revisions, skipping any that a running process still holds.
---
# Telemetry
Maki can export OpenTelemetry metrics and events to a collector you run. It is
off by default, and once enabled it only ever sends data to the endpoint you
configure.
The format matches Claude Code's telemetry, down to the environment variable
names, so a dashboard you already built mostly works.
## What gets exported
Two signals:
- **Metrics**: counters for sessions, tokens, cost, lines changed, permission
decisions, commits, pull requests, and time spent working.
- **Events**: one OTLP log record per prompt, API call, API error, tool result
and permission decision.
## What does not
- No prompt text and no tool input, unless you ask with `log_user_prompts` or
`log_tool_details`. Tool input is the whole
input: `bash` commands, `write` content, `edit` strings, file paths. Only
turn these on while debugging.
- No model output, and no provider error bodies: an API failure reports its
status code, because the body is often the request echoed back.
- No environment variables.
- No user or organisation identity. Maki has no idea who you are and does not
invent an id either. If you want team labels, add them yourself through
`resource_attributes`.
## Quick start
Run a collector on the usual ports, then in `init.lua`:
```lua
maki.setup({
telemetry = {
enabled = true,
metrics_exporter = "otlp",
logs_exporter = "otlp",
protocol = "grpc",
endpoint = "http://localhost:4317",
},
})
```
For HTTP instead of gRPC, set `protocol = "http/protobuf"` and
`endpoint = "http://localhost:4318"`.
Maki appends `/v1/metrics` and `/v1/logs` to that endpoint, as the OTLP spec
says to. A per-signal endpoint is used exactly as written.
No collector yet? Set `metrics_exporter = "console"` and everything is
written to the maki log file as OTLP/JSON instead.
## How it works
```
call sites --try_send --> bounded queues --> background task --> collector
emit() events aggregate
(one atomic load measurements batch
when disabled) retry
```
A call site does one relaxed atomic load, and when telemetry is off that is
the whole cost. When it is on, the value goes into a bounded channel with
`try_send`. If the channel is full the value is dropped and counted, and the
count is logged once per export interval. Exports run on a background task, so
a slow collector cannot stall a turn, and a failed export ends up as a line in
the log file.
## Configuration
Every setting lives in the `telemetry` table of `init.lua` and also has a
matching environment variable. **The environment variable wins.** The full
list of keys, their variables, types and defaults is in the generated
[Configuration](/docs/configuration/#telemetry) reference.
```lua
maki.setup({
telemetry = {
enabled = true,
metrics_exporter = "otlp",
logs_exporter = "otlp",
protocol = "grpc",
endpoint = "http://localhost:4317",
headers = { ["x-api-key"] = "secret" },
resource_attributes = { team = "core", env = "dev" },
},
})
```
All durations are in milliseconds and floored at 100ms, so a zero cannot make
the export loop busy-spin. Exporter values are `otlp`, `console`, `none`, or
a comma-separated mix, and a repeat is ignored. Protocols are `grpc`,
`http/protobuf` and `http/json`.
One setting exists only in the environment: `OTEL_SDK_DISABLED=true` turns
telemetry off no matter what anything else says, so you can disable it across
a whole team without editing anyone's `init.lua`.
Per-signal settings like `metrics_endpoint` override the generic one, but only
within the same source: the environment always beats `init.lua`, so a generic
endpoint set in the environment overrides a `metrics_endpoint` written in Lua.
Headers merge instead: a
per-signal header replaces the generic one with the same key and the rest
stay. That is a deliberate departure from the spec, where per-signal headers
replace the generic list outright.
`service_name` wins over a `service.name` key in `resource_attributes`. If
neither is set, the service is `maki`.
## Resource
Every export carries `service.name`, `service.version`,
`telemetry.sdk.{name,language,version}`, `os.type` and `host.arch`, plus
anything you add through `resource_attributes`. Your attributes win if a
key collides.
## Standard attributes
Every metric and event carries `terminal.type`. Events also carry `session.id`,
`app.version`, `event.name` and `event.sequence`, a counter that orders events
emitted in the same nanosecond. The time of an event is on the record itself,
as `timeUnixNano`.
Metrics get `session.id` and `app.version` only when you ask for them, because
both multiply metric cardinality. `session.id` is on by default, `app.version`
is not.
## Metrics
All of them are monotonic sums with delta temporality by default.
| Metric | Unit | Attributes |
| --- | --- | --- |
| `maki.session.count` | | `start_type` = `fresh`, `resume`, `continue` |
| `maki.token.usage` | tokens | `type` = `input`, `output`, `cacheRead`, `cacheCreation`, plus `model` and `provider` |
| `maki.cost.usage` | USD | `model`, `provider` |
| `maki.lines_of_code.count` | | `type` = `added`, `removed` |
| `maki.tool.decision` | | `tool_name`, `decision` = `accept`/`reject`, `source` |
| `maki.commit.count` | | |
| `maki.pull_request.count` | | |
| `maki.active_time.total` | s | `type` = `cli` |
`maki.cost.usage` is an estimate from the model's price table. A model with no
published price contributes nothing.
Claude Code counts decisions only for edit tools. Maki's permission model
covers every tool, so `maki.tool.decision` carries a `tool_name` and a
`source` saying where the decision came from: `rule`, `yolo`, `user_once`,
`user_session`, `user_always`, or `user_abort` when the prompt never got an
answer.
`maki.active_time.total` measures how long the agent was working, from the
moment a prompt is accepted until the run ends, whether it succeeded or not.
There is no keyboard-idle tracking yet, so no `type=user`.
Subagents are excluded from this metric and from `maki.user_prompt`. They run
inside their parent's time window and nobody typed their prompt, so counting
them would inflate busy time past wall clock and count prompts that were never
written.
## Events
All events are OTLP log records at severity INFO. The payload is in
attributes, and the body is empty.
| Event | Attributes |
| --- | --- |
| `maki.user_prompt` | `prompt_length`, and `prompt` only with `log_user_prompts` |
| `maki.api_request` | `model`, `provider`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens`, `cost_usd`, `duration_ms`, `stop_reason` |
| `maki.api_error` | `model`, `provider`, `error`, `status_code`, `attempt`, `duration_ms` |
| `maki.tool_result` | `tool_name`, `tool_source`, `success`, `duration_ms`, `error_type`, and `tool_input` only with `log_tool_details` |
| `maki.tool_decision` | `tool_name`, `decision`, `source` |
`duration_ms` on `maki.tool_result` is wall clock time: a tool that sat behind
a permission prompt includes the wait for your answer.
`error_type` is a coarse bucket (`timeout`, `not_found`, `permission_denied`,
`invalid_input`, `cancelled`, `error`) rather than the raw message, so it
stays useful as a group-by.
`error` follows the same idea. A provider's error body often just echoes your
request back, so an HTTP failure reports `API error (429)` plus the status
code. Errors raised by maki itself, like a stream timeout, are reported
verbatim.
There is no `request_id`: maki's providers do not expose response headers yet.
## Verifying
The cheapest check is the console exporter. Run maki with
`metrics_exporter = "console"`, do something, quit, and look for
`otel console export` in the log file.
Against a real collector, `maki.session.count` should appear within one metrics
interval (60 seconds by default). Shorten it while testing with
`metrics_interval_ms = 5000`.
## Troubleshooting
Telemetry problems never show up in the UI. They all go to the log file, so
start there.
**Nothing arrives.** Check that `enabled` is set and that an exporter is not
`none`. Maki logs `telemetry enabled` at startup when it is actually on.
**`telemetry disabled` in the log.** A setting failed to parse. The message
names the key, the value it got, and what it expected.
**gRPC fails immediately.** Maki speaks cleartext h2c with prior knowledge,
which is what collectors expect on port 4317. If yours does not, switch to
`protocol = "http/protobuf"` and port 4318.
**`otel queue full` warnings.** Events are produced faster than the collector
accepts them. Raise `logs_max_queue_size`, or shorten `logs_interval_ms` so
batches go out more often.
**Exports look truncated.** `content_max_length` caps prompt and tool input
text at 10 KB by default.
Related pages: [Configuration](/docs/configuration/#telemetry),
[Token Economy](/docs/token-economy/).
---
# Hooks
Maki has two ways for Lua to react to what the agent does.
| You want to | Use |
| --- | --- |
| Know that something happened | [Autocmds](/docs/lua-api/#maki-api-create_autocmd) |
| Change what happens, or stop it | Slots |
Autocmds are notifications. Many plugins can listen to one event, they run in no
particular order, and what they return is ignored. Slots are a chain: each layer
gets the value, decides, and passes it down by calling `prev`. The last layer
registered runs first.
Both come from Neovim. An autocmd matches `nvim_create_autocmd`, and a slot
plays the role `vim.ui.select` plays there, with the wrapping made explicit so
two plugins can layer the same point without capturing each other's function.
## Tool slots
Every tool has two slots that maki fires itself, so the tool's author does not
have to add a hook point. Both fire from the single function every tool call
passes through, so builtins, MCP tools, and ACP client tools behave alike:
| Slot | Fires | Gets |
| --- | --- | --- |
| `tool..input` | before the input is parsed or checked against your permission rules | the call the model wrote |
| `tool..output` | on the result of a call that ran, including a failure or a permission refusal | `{ text, is_error }` |
A call an input layer stopped never reaches the output slot: the reason came
from a layer, so there is nothing left to filter. A name that resolves to no
tool fires neither slot.
Layers take `function(prev, value, ctx)` and answer in one of three ways:
- return a table: it replaces the value for the rest of the call
- return nothing: the value is left alone
- return `nil, reason`: the call is stopped and the model reads `reason`
Stopping means something different per stage. On `input` the tool never runs and
`reason` becomes the tool result, marked as an error. On `output` the work is
already done, so `reason` only replaces the text the model reads.
`ctx` carries `tool`, `tool_id`, `session_id`, and `origin`. Origin is `"model"`
for a call the model made and `"nested"` for one made on its behalf by `batch`,
`code_execution`, or a plugin calling `maki.agent.call_tool`. Nested calls have
no id of their own, so their `tool_id` is empty. A subagent runs its own model,
so its calls arrive as `"model"` under the subagent's `session_id`.
### Rewriting a command
The model reaches for `grep -r` even when `rg` is installed. Denying costs a
model round trip every time it happens. A rewrite fixes the command in place:
```lua
maki.api.set_slot("tool.bash.input", function(prev, input, ctx)
local rewritten = input.command:gsub("^grep %-r ", "rg ")
if rewritten == input.command then
return
end
input.command = rewritten
return prev(input, ctx)
end)
```
`rg` and `grep -r` do not search the same files: `rg` skips what `.gitignore`
lists, hidden files, and binaries. That is why maki does not do this for you.
### Blocking a command
When there is no good rewrite, stop the call and say why. The reason reaches
the model as the tool result:
```lua
maki.api.set_slot("tool.bash.input", function(prev, input, ctx)
if input.command:find("git push %-%-force") then
return nil, "Force pushing is not allowed here. Open a PR instead."
end
return prev(input, ctx)
end)
```
### Trimming output
An output layer runs before the output becomes part of the conversation, so what
it drops is never paid for again:
```lua
local MAX = 200
maki.api.set_slot("tool.bash.output", function(prev, out, ctx)
local lines = {}
for line in out.text:gmatch("[^\n]+") do
if not line:match("^%s*Compiling ") then
table.insert(lines, line)
end
end
out.text = table.concat(lines, "\n", 1, math.min(#lines, MAX))
return prev(out, ctx)
end)
```
A replacement table has to carry `text`. Without it the output is left alone and
the reason is logged. Set `is_error` to turn a success into a failure, or a
failure into a success.
An output slot fires only when the text is the whole output. Tools the UI renders
from fields, like `read` or `edit`, are excluded, because prose edited underneath
would disagree with the display. So are tools whose result carries structured
state saved with the session, like `batch` or `question`. That state is what gets
re-rendered on restore, so a value redacted in the text would come back after a
restart.
## Rules
**Permissions judge what runs.** An input layer runs before the schema check and
before rules are resolved, so a layer cannot turn `allow bash: git status` into
something else. The prompt you see names the rewritten call.
**A layer borrows the tool's capability.** Wrapping `tool.bash.input` decides
what bash runs, so the plugin holding that layer needs `run`, the capability the
bash tool declares. A layer from a plugin without it is skipped and the rest of
the chain still runs. The check happens per call, so a missing grant shows up in
debug logs rather than as a warning at load.
| Tool | A layer needs |
| --- | --- |
| declares a permission, like `bash` | that permission |
| declares none: `read`, `batch`, MCP tools, ACP client tools, `tool_search` | every permission |
Declaring no capability does not mean a tool uses none. `batch`,
`code_execution` and `task` declare nothing while invoking any other tool, so
reading undeclared as free would hand a plugin everything. Undeclared costs the
maximum instead. See [plugin permissions](/docs/lua-api/#plugin-permissions).
**A layer may wait, within a window.** Chains are async, so a layer can read a
file or run a job before it decides. It runs inside the call it is filtering, so
cancelling the call cancels the layer too. Each stage gets whatever the call has
left of its own deadline, capped at 60 seconds. A layer still running when the
window closes is dropped, and the call proceeds as if that layer had passed the
value along.
Cancellation lands differently on the two stages. An input layer cut short stops
the call, because nothing has run yet and nobody is left to read a result. An
output layer cut short leaves the output as it found it, since the work is
already done.
**A broken layer is skipped.** If a layer throws, the chain continues as if it
had passed the value along, and the error is logged with the plugin name.
**Order is registration order.** The last layer registered is the outermost one
and sees the value first. Package load order decides this, so avoid writing two
layers that only work in one order.
**`prev` is single use.** Calling it twice throws. Everything below a layer runs
once per call.
**History keeps the call the model wrote.** The tool header, the permission
prompt, and the tool result show what ran. If a rewrite changes what the call
means, tell the model by appending a line in the output layer.
**Idle slots cost nothing.** A tool with no layers never crosses into Lua, and a
chain that hands back the value it was given leaves the original untouched.
**JSON null arrives as `nil`.** A Lua table cannot hold a null, so a null field
and a field that was never there look the same inside a layer. Maki carries
nulls across for you, which is what makes an untouched value a true no-op. The
cost: you cannot delete a field whose value is null, because maki cannot tell
that apart from leaving it alone. Set it to another value, or deny the call.
## Wrapping every tool
Slot names are per tool, so a layer on `tool.bash.input` costs nothing when
`read` is called. To cover all of them, loop over the registry:
```lua
local function redact(prev, out, ctx)
out.text = out.text:gsub("sk%-%w+", "[redacted]")
return prev(out, ctx)
end
for _, tool in ipairs(maki.api.get_tools()) do
maki.api.set_slot("tool." .. tool.name .. ".output", redact)
end
```
Most builtins declare no capability, so this loop only does anything for a plugin
granted every permission. Run it from a plugin without them and each layer is
skipped when its tool is called.
This sees the tools registered so far, so run it from `init.lua`, which loads
after the builtin plugins. It also misses MCP tools, which arrive when their
server connects. Naming one slot directly has no such ordering rule: `set_slot`
accepts a name before anything registers it.
## Plugin slots
A plugin can define an extension point of its own with
[`declare_slot`](/docs/lua-api/#maki-api-declare_slot). The declaring plugin
owns the name and supplies the default, and anyone can wrap it with `set_slot`:
```lua
-- owner
local render = maki.api.declare_slot("myplugin.render", function(text)
return text:upper()
end)
-- anyone
maki.api.set_slot("myplugin.render", function(prev, text)
return "[" .. prev(text) .. "]"
end)
-- render("hi") now returns "[HI]"
```
Names starting with `tool.` are reserved for maki, which fires them at points
whose ordering it guarantees.
Use `maki.api.get_slots()` to see who owns and who wraps each slot.
## Limits
A layer has no agent context, so `maki.agent.call_tool` and
`maki.agent.session` are out of reach inside one. Read files, run jobs, and
decide from those.
---
# Skills
A skill is a short Markdown how-to that the agent loads only when it needs it. The `skill` tool shows the agent what is available, and when it picks one, the file drops into the conversation and the agent follows it.
Write one for anything you keep explaining: how you cut a release, how you write a maki plugin, how a PR should look in this repo. `AGENTS.md` is always in context and always costs tokens. A skill costs nothing until it is loaded, only its name and description sit in the tool list. So big skills are fine.
## Where skills live
A skill is a directory with a `SKILL.md` inside. Maki looks for them every time the `skill` tool runs (and once at startup, to build the list). When two skills share a name, the one found last wins:
1. The builtin `maki-plugin-dev` (if enabled)
2. `~/.config/maki/skills/` (Windows: `%APPDATA%\maki\skills\`)
3. `~/.claude/skills/`, `~/.config/opencode/skills/`, `~/.agents/skills/`
4. In your project, walking from the current directory up to the `.git` root, at each step: `.maki/skills/`, `.claude/skills/`, `.opencode/skills/`, `.agents/skills/`
So project skills beat personal ones, and a skill at the repo root beats one with the same name deeper down. The `.claude`, `.opencode` and `.agents` dirs are there so skills you already wrote for other agents keep working.
[Folder trust](/docs/folder-trust/) does not gate skills. Every project skill
name and description is in the system prompt from startup, trusted or not, so
read a repository's skills before you work in it.
Only `SKILL.md` is read. If you want extra notes, put them in files next to it and link them from the body, like `./notes.md`.
## Writing one
Make a directory under `.maki/skills/` and put a `SKILL.md` in it:
```
.maki/skills/git-release/SKILL.md
```
```markdown
---
name: git-release
description: Cut a tagged release and open the changelog PR
---
## Steps
1. Read `CHANGELOG.md` and the commits since the last tag.
2. Propose a semver bump and a short release summary.
3. Only tag after the user confirms.
```
The frontmatter is optional. Without it, the directory name is the skill name and the whole file is the body. An empty body is skipped. The `description` is what the model reads when picking a skill, so make it specific.
## How it gets used
The `skill` tool lists every skill it found, the agent calls it with a name and gets the body back. A wrong name errors and reprints the list so the model can pick again.
Skills are not slash commands: typing `/git-release` does nothing unless you also add a [custom command](/docs/commands/#custom-commands). Ask the agent to use a skill, or let it pick one on its own.
## The builtin: maki-plugin-dev
Maki ships one skill, `maki-plugin-dev`. It teaches the agent how to write maki Lua plugins, and on load it writes the full Lua API reference to a file in the state dir, so the agent can read it in pieces instead of swallowing it whole. It carries the same guide you can read in [Plugins](/docs/plugins/), so "write me a plugin that ..." is usually enough. Turn it off if you never write plugins:
```lua
-- ~/.config/maki/init.lua
maki.setup({
plugins = {
skill = { plugin_dev = false },
},
})
```
---
# Headless Mode
Run Maki non-interactively with `--print` / `-p`. Useful for scripts, CI, and automation.
```bash
maki "explain this codebase" --print
```
Pipe via stdin:
```bash
echo "list all TODO comments" | maki -p
```
A headless run never asks about [folder trust](/docs/folder-trust/), so a
project `.maki` directory it has no stored answer for is skipped and reported on
standard error. In a container, add `--trust` to load it for that run.
## Output Formats
| Format | Description |
|--------|-------------|
| `text` | Raw response only (default) |
| `json` | Single JSON object with metadata |
| `stream-json` | JSONL stream, one event per line |
```bash
maki "fix the tests" --print --output-format json
```
JSON output includes `type`, `subtype`, `is_error`, `duration_ms`, `num_turns`, `result`, `stop_reason`, `session_id`, `total_cost_usd`, and `usage`.
Add `--verbose` to include full turn-by-turn messages in the output.
## Claude Code Compatibility
Maki's `--print` is a drop-in replacement for Claude Code:
```bash
# Before
claude "fix the bug" --print --output-format json
# After
maki "fix the bug" --print --output-format json
```
Same JSON fields, same `--output-format` options, same `--verbose` behavior. Scripts that parse Claude Code output work unchanged.
## SDK / Stream Mode
For tools like Conductor, Windsurf, or custom orchestrators that speak the Claude Code SDK wire protocol, use `--input-format stream-json`:
```bash
maki --print --input-format stream-json
```
This enters a bidirectional NDJSON loop over stdio instead of the one-shot print path:
```
your orchestrator maki --print --input-format stream-json
│ │
│ {"type":"user",...} (stdin) │
├─────────────────────────────────────────────►
│ │
◄─────────────────────────────────────────────┤
│ system / assistant / stream_event / result │
│ one JSON object per line (stdout) │
```
Inbound messages (`user`, `control_request`, `control_response`, `control_cancel_request`) drive the agent; outbound messages match the Claude Code SDK shape. Under the hood it reuses the same driver as the TUI and ACP server, so sessions, tools, and permissions all work the same way.
SDK-only flags (`--system-prompt`, `--max-turns`, `--session-id`, `--fork-session`, `--permission-mode`, `--include-partial-messages`, ...) are listed in the [CLI flag matrix](/docs/cli/#flags-by-run-path).
Two caveats:
- One-shot `--print` always starts a **new** session in **build** mode. Plan mode and session resume need the SDK path (or the TUI).
- The plan file for SDK `--permission-mode plan` is `./plan.md` under cwd, not the state-dir `plans/.md` files the TUI uses.
### Quick example
```bash
echo '{"type":"user","message":{"content":"explain this repo"}}' \
| maki --print --input-format stream-json --max-turns 3
```
## Examples
Pipe compiler errors back for a fix:
```bash
cargo build 2>&1 | maki "Fix these compiler errors." --print --yolo
```
Generate a changelog from recent commits:
```bash
git log --oneline v1.2.0..HEAD | maki "Write a user-facing \
changelog grouped by: Added, Changed, Fixed. Skip chores." --print
```
Automated PR summaries in CI:
```bash
SUMMARY=$(git diff main..HEAD | maki "Write a 2-3 sentence \
summary of this change for a PR description." --print)
gh pr edit --body "$SUMMARY"
```
Migrate an API across many files:
```bash
grep -rl 'old_api_call' src/ | while read file; do
maki "In $file, migrate old_api_call() to new_api_call(). \
Keep behavior identical." -p --yolo --allowed-tools Read,Edit
done
```
Cost tracking:
```bash
maki "refactor the database layer" -p --output-format json | jq '.total_cost_usd'
```
---
# ACP (Agent Client Protocol)
Run Maki inside your editor. `maki acp` starts an [ACP](https://agentclientprotocol.com/) server over stdio, so any ACP-capable editor (like [Zed](https://zed.dev/)) can drive Maki as its coding agent.
```bash
maki acp
```
## Zed setup
Add Maki as a custom agent in Zed's `settings.json`:
```json
"agent_servers": {
"Maki": {
"default_config_options": {
"model": "deepseek/deepseek-flash"
},
"type": "custom",
"command": "maki",
"args": ["acp"],
"env": {}
}
}
```
The `model` value is a `provider/model-id` spec, same format as `maki --model`.
## What works
- **Sessions persist.** Loading a session replays the full conversation in the editor, so you can resume where you left off.
- **Model switching.** Pick a model from the editor's dropdown, mid-session. All configured providers show up. Providers that list their models over the wire (OpenRouter and friends) are discovered in the background, so the dropdown keeps filling up for a moment after the session starts, one provider at a time.
- **Modes.** Switch between build (full access) and plan (plan-file writes only) from the editor.
- **Permissions.** Tool permission prompts appear in the editor: allow or reject, once or always.
- **Questions.** The `question` tool becomes a native form in the editor (ACP elicitation). If the client does not support elicitation, the tool is dropped and the model asks in plain text.
- **Live tool calls.** Tool progress streams as it happens, including sub-agents and batched calls.
- **Images and context.** Prompts can include images and editor-attached files.
Authentication, providers, and permissions come from your normal Maki config. Set up [providers](/docs/providers/) first and ACP sessions just work.
The editor picks each session's working directory, and that folder's own
[trust](/docs/folder-trust/) decides whether its `.maki` config loads. ACP never
asks, so trust a project with `maki trust add` in it, or start the server with
`maki --trust acp`.
```bash
maki acp
maki acp -m anthropic/claude-sonnet-4-6
maki acp --yolo
maki --no-jit acp
```
`maki acp` only takes `-m` / `--model` and `--yolo` as subcommand flags. Global flags like `--no-jit` must come before the subcommand (`maki --no-jit acp`, not `maki acp --no-jit`).
Plan mode in ACP uses the same state-directory plan files as the TUI (`…/plans/.md`), not the SDK's `./plan.md`.
---
# Writing maki plugins
Maki plugins are plain Lua files (Luau) that run inside maki. A plugin can
register tools the LLM calls, slash commands, keymaps, prompt hints, and
custom UI. Everything lives under the global `maki` table. The full API
reference is at the end of this document.
## Where plugin code goes
Plugins live in the maki config dir. There are two of them, same layout:
- `~/.config/maki/` - global, every project (if `~/.maki/` exists, maki reads
that one first)
- `/.maki/` - this project only
```
init.lua the only file maki runs; require()s plugins, calls maki.setup()
lua/.lua plugin modules, loaded by require("")
plugin.toml permission grants for every Lua file in the dir
```
Nothing under `lua/` loads on its own. A module name is its path under `lua/`
without the extension: `lua/browser.lua` is `require("browser")`,
`lua/acme/tools.lua` is `require("acme.tools")`. `require` is sandboxed to
that directory, you cannot reach files outside it.
## Creating a plugin
1. Write the code in `~/.config/maki/lua/.lua`. The `maki` global is
already there, nothing to import. For a project-only plugin use
`/.maki/` here and in every step below.
```lua
maki.api.register_tool({
name = "hello",
description = "Say hello to a name.",
parameters = { type = "object", properties = { name = { type = "string" } }, required = { "name" } },
handler = function(args)
return { llm_output = "hello " .. args.name }
end,
})
```
2. Load it from `~/.config/maki/init.lua`, creating that file if missing:
```lua
require("hello")
```
3. Grant the permissions it needs in `~/.config/maki/plugin.toml`, creating
that file if missing. Without the file every gated call is denied.
```toml
[permissions]
fs_read = true
run = true
```
4. Run `/reload`, then read the log as described below, to see that it loaded
and what it printed.
Leave `maki.api.register_options` to bundled plugins: maki rejects a
`plugins.` table for a plugin it does not ship, and startup fails. Keep
settings in a local table, or export a `setup(opts)` function `init.lua` calls.
## Permissions and plugin.toml
Sensitive APIs are gated per plugin file, and a plugin without a
`plugin.toml` next to it gets nothing. The gates and the file format are
in [the reference](/docs/lua-api/#plugin-permissions). Set
`min_maki_version` there when a plugin needs a newer Maki Lua API.
## Development loop
`/reload` rebuilds plugins and config in place, no restart needed. Until it
runs, an edited plugin is still the old one.
To debug, add `maki.log.info|warn|error(...)` calls. They write to `maki.log`
in the dir `maki.env.logs_dir()` returns (Linux: `~/.local/logs/maki/`). The
log keeps `info` and above. Set `MAKI_LOG=debug` to also keep `maki.log.debug`,
or `MAKI_LOG=maki_lua=trace` to narrow it to one target. When a backtrace comes
out useless, start maki with `--no-jit`: plugins then run on the interpreter,
with full debug info.
## Conventions
- Fallible runtime calls return a `(value, err)` pair; check `err` before using `value`.
- Tool handlers report failures with `{ llm_output = "error: ...", is_error = true }`, not by raising.
- The model picks tools by reading `description`, so state precisely what the tool does and when to use it.
- Reusable helpers ship with maki; see "Shared helper modules" in the API reference.
## A complete real example
The bundled `glob` tool, verbatim: schema, header and restore hooks, error
handling, LLM output truncation, collapsible UI view. It is a bundled plugin,
so it opens with `register_options`, which your own plugin skips:
```lua
local truncate = require("maki.truncate")
local ToolView = require("maki.tool_view")
local shorten_path = require("maki.shorten_path")
local output_limits = require("maki.output_limits")
local NO_FILES_FOUND = "No files found"
local opts = maki.api.register_options(output_limits.extend({
search_result_limit = { default = 100, min = 10, desc = "Max files returned per search." },
}))
local function glob_view_opts(ctx)
local tol = ctx:tool_output_lines()
return { max_lines = (tol and tol.other) or 3, keep = "head" }
end
maki.api.register_tool({
name = "glob",
kind = "search",
description = [[Find files by glob pattern.
- Respects .gitignore.
- Returns absolute paths sorted by modification time (newest first).
- Prefer speculative parallel searches over sequential rounds of glob+grep.]],
schema = {
type = "object",
properties = {
pattern = { type = "string", description = "Glob pattern (e.g. **/*.rs, src/**/*.ts)", required = true },
path = { type = "string", description = "Directory to search in (default: cwd)" },
},
},
header = function(input)
local buf = maki.ui.buf()
local spans = { { shorten_path(input.pattern or ""), "tool" } }
if input.path then
spans[#spans + 1] = { " in ", "dim" }
spans[#spans + 1] = { shorten_path(input.path), "path" }
end
buf:line(spans)
return buf
end,
restore = function(_input, output, _is_error, ctx)
return ToolView.restore(output, glob_view_opts(ctx))
end,
handler = function(input, ctx)
local pattern = input.pattern
if not pattern then
return { llm_output = "error: pattern is required", is_error = true }
end
local limit = opts.search_result_limit
local max_lines, max_bytes = output_limits.resolve(opts, ctx)
local files, err = maki.fs.glob(pattern, {
path = input.path,
gitignore = true,
sort = "mtime",
limit = limit,
})
if not files then
return { llm_output = "error: " .. err, is_error = true }
end
if #files == 0 then
return { llm_output = NO_FILES_FOUND }
end
local lines = {}
for i, f in ipairs(files) do
lines[i] = shorten_path(f)
end
local text = table.concat(lines, "\n")
local llm_output = truncate(text, max_lines, max_bytes)
local buf = maki.ui.buf()
local view = ToolView.new(buf, glob_view_opts(ctx))
for _, line in ipairs(lines) do
view:append(line)
end
view:finish()
buf:on("click", function()
view:toggle()
end)
return {
llm_output = llm_output,
body = buf,
}
end,
})
```
## Full API reference
Every module, function, and method is in the [Lua API reference](/docs/lua-api/).
The agent gets the same document on disk through the builtin
`maki-plugin-dev` skill, so asking it to write a plugin for you works
without pasting any of this.
---
# Token Economy
Maki's whole design falls out of one fact about agent loops: the conversation is re-sent to the model on every turn.
```
turn 1 [system + prompt] ─► model ─► tool call
turn 2 [system + prompt + result 1] ─► model ─► tool call
turn 3 [system + prompt + result 1 + 2] ─► model ─► ...
```
A tool result does not cost its tokens once. It costs them again on every turn until the session ends or history is compacted. `cat` a 2000-line file on turn 2 of a 40-turn session and you pay for it 38 more times. Prompt caching softens the price, not the principle: cache reads still cost, and a bloated context also makes models measurably dumber.
So Maki attacks the two multipliers: how much each step adds to context, and how many steps there are.
## Smaller results
**index instead of read.** The `index` tool returns a tree-sitter skeleton of a source file: imports, types, signatures, line numbers. Usually 70-90% smaller than the file itself. The agent indexes first, then reads only the ranges it needs.
```
read main.rs index main.rs
──────────── ─────────────────────────────
1400 lines in context 60 lines of signatures
+ read offset=812 limit=40
```
**Subagents as garbage collectors.** A `task` subagent gets its own throwaway context. It can grep, read, and hit dead ends as much as it wants; only its final summary returns to your conversation. The mess is collected when it exits. Model tiers make this cheap too: delegate a search to a weak model at a fraction of the cost, keep the strong model for judgment.
```
main context subagent context (discarded)
──────────── ────────────────────────────
task("find auth") ───────► glob, grep ×6, read ×9, ...
◄─────── "JWT middleware, auth.rs:120"
one line stays ~20k tokens never seen
```
**Deferred MCP tools.** An MCP server with 100 tools would ship 100 definitions in every request. Maki loads a single `tool_search` tool instead; the model searches when it actually needs something and only the matches load. See [MCP](/docs/mcp/#tool-search).
**Truncation everywhere.** Tool output is capped (`agent.max_output_bytes`, `agent.max_output_lines`), overlong grep lines are skipped, and every builtin tool description nags the model to read only what it needs. The nagging works.
**Interrupted work is not wasted.** Press Esc on a long tool, or let its deadline hit, and whatever it printed so far still reaches the model, tagged as partial: bash keeps its streamed lines, `code_execution` the script output, a `task` subagent its half transcript. Otherwise the next turn starts from nothing and you pay to run it all again.
## Fewer round-trips
Every round-trip re-sends the context, so round-trips are the other half of the bill.
**batch** runs independent tool calls in one turn: one request, N results.
**code_execution** goes further: a Python sandbox where tools are async functions. Chained calls, loops, and filtering happen inside the sandbox; only what the script prints enters context.
```
without with code_execution
───────────────────── ─────────────────────────────
glob → 300 paths results = gather(read × 300)
read × 300 → 300 files filter in python
300 turns, every file print("3 files call foo_v1")
in context forever 1 turn, 1 line in context
```
**Compaction** resets the multiplier when a session runs long: older turns are summarized and dropped. [Context](/docs/context/#when-the-window-fills) has the details.
## Watching it work
`/usage` shows the token breakdown of the current session, and `--output-format json` in [Headless Mode](/docs/headless/) reports `total_cost_usd` per run. Cheap is a feature you can measure.
Each turn is priced when it happens and that number is stored with the session. Prices move (DeepSeek, for one, doubles every rate during peak UTC hours), so a total re-priced later would be a guess. What you see is what you were billed.
---
# Context
Everything the model knows about your project passes through one context window, and every token in it costs money and attention. This page covers what Maki puts there, when, and where you should put things so they land well.
## What loads when
```
session start (paid every request) on demand (paid when used)
────────────────────────────────── ─────────────────────────────────
system prompt file contents read / index / grep
tool definitions skill bodies skill tool
instruction files (AGENTS.md, ...) memory notes memory tool
memory tag names subdir rules first read there
skill names + descriptions MCP tool defs tool_search
```
The left column is the fixed overhead of every single request, so Maki keeps it small on purpose: a skill contributes one description line, memories one list of tags, a big MCP server one search tool. The bodies stay on disk until the agent asks.
## Instruction files
At session start Maki walks from the project git root down to the working directory (no `.git` root, only the cwd). In each directory it loads **one** project instruction file, first match wins:
| Order | File |
|------|------|
| 1 | `AGENTS.md` |
| 2 | `CLAUDE.md` |
| 3 | `.github/copilot-instructions.md` |
| 4 | `COPILOT.md` |
| 5 | `.cursorrules` |
| 6 | `.windsurfrules` |
| 7 | `.clinerules` |
| 8 | `CONVENTIONS.md` |
| 9 | `GEMINI.md` |
| 10 | `CODING_AGENT.md` |
After the match it always loads `AGENTS.local.md` from the same directory if present: that one is yours, keep it gitignored. Closer directories win on conflicts. Finally one global `~/.config/maki/AGENTS.md` for preferences that follow you across projects.
```
~/repo/AGENTS.md loaded (root)
~/repo/AGENTS.local.md loaded (yours, gitignored)
~/repo/api/CLAUDE.md loaded when cwd is ~/repo/api, wins over root
~/repo/web/AGENTS.md not loaded yet...
~/.config/maki/AGENTS.md loaded (global)
```
That `web/AGENTS.md` is not dead weight. The first time the agent `read`s a file under a subdirectory whose instruction file was never loaded, Maki pulls it in. Monorepo rules live next to the code they govern and cost nothing until someone works there.
Put coding conventions, repo quirks, and off-limits directories in these files. Keep them short; the next section explains why.
## Four places to put knowledge
All four end up in context, but at different times and prices:
| | Loaded | Costs | Good for |
|---|--------|-------|----------|
| `AGENTS.md` | every session | every request | short rules: conventions, build commands, no-go areas |
| [Skills](/docs/skills/) | when the agent picks one | a description line until then | long playbooks: release process, plugin authoring |
| Memory | when the agent recalls a tag | tag names until then | gotchas the agent learns while working |
| [Commands](/docs/commands/) | when you type `/name` | nothing until invoked | prompts you keep retyping |
Rule of thumb: when `AGENTS.md` grows past a screen, the new material probably wants to be a skill. `AGENTS.md` is a tax on every request; a skill is a tax only on the sessions that need it.
## When the window fills
Long sessions eventually approach the model's context limit. Maki reserves a slice of the window (`agent.compaction_buffer`, default 20%) and before running out it summarizes the older turns and continues from the summary. `/compact` triggers it early, `/compact keep the repro steps` steers that one summary, `/usage` shows where the tokens went, and `agent.compaction_instructions` steers every summary.
Compaction replaces the older turns in the session's on-disk log with the summary. The dropped turns are not lost: before the rewrite, Maki parks the previous log at `sessions/archive//.jsonl` in the [state directory](/docs/configuration/#directory-layout). It keeps the newest three per session, and at most 32 MB of them. The names count up, so the highest number is the newest.
An archive is a complete session file, so `jq` or an editor reads it as it is. To open one in Maki you have to put it back in place of the live log, which drops the session's current state, so move that out of the way first:
```sh
cd ~/.local/state/maki/sessions
mv .jsonl .jsonl.bak
cp archive//.jsonl .jsonl
maki -s
```
`MAKI_DISABLE_AUTOCOMPACT=1` turns off the automatic compaction. A manual `/compact` still compacts.
Related: [Token Economy](/docs/token-economy/) for why all this frugality exists, [Configuration](/docs/configuration/) for the knobs.