# 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
Quick StartInstall, connect a provider, first session. Configurationinit.lua, the small Lua script where all settings live.
Guides getting things done
SkillsWrite Markdown playbooks the agent loads on demand. PluginsAdd your own tools and commands in Lua, or let the agent write them. Headless Mode--print for scripts and CI. Drop-in Claude Code compatible. ACPDrive Maki from your editor, like Zed, over the Agent Client Protocol.
Concepts wondering why
Token EconomyWhere tokens go in an agent loop, and every trick Maki uses to spend fewer of them. ContextWhat enters the model's context and when, and where to put project knowledge.
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.