Documentation
Docs for airlok v0.9.2, generated from the README at that tag.
- Install
- Usage
- Interactive sessions
- Sessions
- Tools
- MCP servers
- Context and AIRLOK.md
- Configuration
- Redaction: what comes back, and what never does
Install
curl -fsSL https://airlok.dev/install.sh | sh
# or
brew install airlok-dev/tap/airlok
# or
npm install -g airlok
# or, built from source
cargo install --git https://github.com/airlok-dev/airlok airlok --locked
install.sh installs the latest release for macOS or Linux. It checks the release's installer against the sha256 digest GitHub publishes for it, then runs it; set AIRLOK_NO_MODIFY_PATH=1 to leave your shell profile alone.
Usage
airlok # interactive session in the current directory
airlok "create hello.txt containing hello" # run one task in the current directory
airlok --plan # start in plan mode: the model researches, then proposes a plan
airlok --resume # continue the latest saved session here (REPL, or with a task)
airlok --resume=<id> "summarise what we did" # continue a particular one
airlok sessions # list saved sessions for this directory
airlok sessions rm <id> # delete one
airlok sessions clean --older-than 30d # delete old sessions from every directory
airlok -v "..." # debug logs on stderr
airlok -y "..." # no confirmations (prints a warning)
airlok --provider openai --model gpt-5.5 "..." # override the provider and model for one run
airlok --show-redactions "..." # list what was redacted (kind and length only)
airlok config init # write a commented config to the user path
airlok config show # print the effective config and the key source
airlok context # print the context block sent with the system prompt, after redaction
airlok redactions # list what the redactor detects and how each kind is treated
airlok mcp list # the configured MCP servers, whether they answer, and their tools
Every write_file and edit_file call shows a diff with line numbers, three lines of context, +/- gutters, and syntax highlighting, and asks Apply? [y]es / [n]o / [a]ll / [q]uit. A diff longer than 40 lines stops at ... N more lines, [v] to view all; v prints the rest and asks again. Every bash call that is not on the allow list shows the command and asks the same way. y applies this one, n sends a rejection back to the model so it can adapt, a approves the rest of that kind for the run, and q aborts the run with a non-zero exit. Prompts are read from the terminal, not stdin, so piped input still works; without a terminal, pass --yes or turn the confirmations off in the config.
Model output is rendered as markdown (headings, emphasis, lists, tables, highlighted code) when stdout is a terminal; set NO_COLOR or redirect stdout for plain text. Runs of read-only tool calls show on the status line and end with one read N files line; -v shows every call.
Interactive sessions
airlok with no task opens a session. It starts with one line (version, model, directory, /help for commands), the prompt shows the model and the directory (gpt-5.5 airlok> ), each line you send is one turn, and the conversation carries across turns. After each turn a dim footer shows the model, how full the context is, and the session id.
While a turn runs, a status line below the output shows a spinner, what airlok is doing (thinking, reading src/main.rs, running cargo test), the seconds so far, and the tokens used this turn. It is cleared before anything else prints. It is off when stdout is not a terminal, when NO_COLOR is set, and with -v, whose logs share the terminal.
| Key | What it does |
|---|---|
| Enter | send the line |
| Alt+Enter | start a new line in the same message. Shift+Enter does the same in terminals that send Esc then Enter for it; most send a plain Enter |
| Tab | take the first / command, @ path, or command argument on offer; Tab again cycles through the rest |
| Right arrow | take the rest of the / command shown after the cursor |
| Esc or Ctrl-C | during a turn, cancel it |
| Ctrl+V | attach an image from the clipboard. A chip like [image 1: 1024x768 png, 210 KB] goes in at the cursor, so the sentence can refer to it; several fit in one message. Alt+V does the same, for terminals that keep Ctrl+V for themselves. Every failure says why on its own line |
| a path to an image | typed, Tab-completed or dropped into the terminal, a path ending .png, .jpg, .jpeg, .gif or .webp is offered as an attachment. Approved, it becomes a chip, so the model gets the picture rather than the path; refused, it stays as typed |
| Ctrl-C | at the prompt, clear the line; on an empty line it does nothing |
| Ctrl-D | save and quit |
| Ctrl-R | search this run's history; Up and Down step through it |
The models airlok offers are the one in use, any named in [models."<id>"], the ones used earlier in this run, and the ones saved sessions here used on this provider. Tab completes from that list after /model , from the two provider names after /provider , and from the efforts the provider accepts after /effort . Switching to an id from that list is taken as given; anything else is read back with the nearest known ids and a question, since a model id that only fails on the next turn is a poor way to learn it was a typo.
Reasoning effort is per model. [models."<id>"] reasoning_effort is the default, /effort reports which of those you are on, and /effort <value> overrides it for the session and is saved with it, so --resume continues on the same effort rather than the config's. Only the openai provider sends it.
A provider that refuses a request is reported as a sentence naming the likely cause: a model or deployment that does not exist, a key the provider rejected, rate limiting, or a request longer than the model's context. The raw body goes to the debug log, so -v still has it, and the session stays open either way.
A cancelled turn keeps the text streamed so far in the history, marked as interrupted; tool calls that had not run are dropped, and you get the prompt back. Keys typed while a turn runs are kept and start the next prompt.
| Input | What it does |
|---|---|
/ at the start | a slash command. A menu of the matching commands and what they do shows as you type. A prefix runs the first match in the menu (/co is /cost); an unknown name gets the closest command suggested |
@ anywhere | @ and part of a path offers matching files and directories from the working directory, fuzzy and gitignore-aware. Tab inserts the path as plain text, without the @ |
! at the start | runs the rest with sh -c in the working directory and prints the output. The command and its output go into the conversation for the model's next turn. bash_timeout_secs applies, and the command cannot read input |
# at the start | appends the rest to ./AIRLOK.md as a list item, creating the file, and rebuilds the context block so it applies from the next turn. A new AIRLOK.md takes precedence over a CLAUDE.md or AGENTS.md beside it, and the confirmation says so. Instructions are read from the repository root, so from a subdirectory the note lands in a file the context block does not read |
| Command | What it does |
|---|---|
/help | list the commands |
/model [<id>] | with no id, pick from the models airlok knows here: arrows, type to filter, Enter to take one, Esc or Ctrl-C to leave it alone. With an id, switch to it; an id airlok has not seen is questioned rather than taken |
/effort [<value>] | the reasoning effort in force for this model and where it came from; with a value, use it for the rest of the session. openai only |
/mcp | list the MCP servers and their tools; /mcp <name> enables a disabled one for this session |
/plan | turn plan mode on or off |
/go | carry out the plan from plan mode, back in normal mode |
/provider [<name>] | with no name, pick between anthropic and openai; with one, switch if a key is available for it, and say which key is missing otherwise |
/cost | tokens used so far, and whether they are estimates |
/compact | summarise older turns to free context |
/config | the effective configuration |
/redactions | what was redacted before leaving this machine (kind and length only) |
/status | version, provider, model and effort, session and turn count, cwd and branch, config files in effect, MCP servers, redaction counts. Names and counts only, never a value |
/context | where the context window is going, part by part, and how near compaction is |
/btw <question> | a side question: same model, no tools, and neither it nor the answer joins the task's history |
/permissions [<name> <value>] | what airlok asks about, and change one for the session; /permissions save writes them to the project config after showing a diff |
/init | propose an AIRLOK.md for this repository, as a diff to approve |
/copy [<n>\|code] | copy the last reply to the clipboard; a number takes the Nth from the end, code the last fenced block |
/diff [<path>] | what airlok changed on disk this session, diffed from before its first write |
/doctor | check the config, key, provider, MCP servers, git, terminal and storage; airlok doctor does the same and exits non-zero on failure. --offline skips the provider request, the one check that leaves the machine |
/goal [<statement>\|clear] | what the session is working toward, kept in the system prompt every turn and shown in the footer |
/clear | start a new session; the current one stays saved |
/exit | save and quit; /quit works too |
Plan mode
/plan, or --plan at startup, switches to plan mode, and the prompt shows [plan]. The model gets only the read-only tools (read_file, glob, grep, list_dir): write_file, edit_file, and bash are left out of the request, the system prompt says they are unavailable, and a call to one of them anyway is refused. The model researches the task and ends its reply with a plan. /go leaves plan mode and sends that plan back as the task, with every tool available again. /plan a second time leaves without running anything. Per-model settings such as reasoning_effort are sent the same way in both modes. Plan mode is not saved with the session, so --resume starts in normal mode unless --plan is given.
Providers in a session
[provider] settings (api_key_cmd, api_key_env, base_url) belong to the configured provider. After /provider switches to the other one, it runs on that provider's default model and its default key variables (ANTHROPIC_API_KEY; AZURE_OPENAI_API_KEY or OPENAI_API_KEY); switching back restores the configured one. Both changes are written to the session file, and --resume continues on the session's last model when it used the configured provider and --model is not given.
Sessions
Every turn, one-shot or interactive, is saved under $XDG_DATA_HOME/airlok/sessions/<hash of the directory>/ (~/.local/share/airlok by default). airlok --resume picks up the latest session for the directory; it rebuilds the context block, so the model sees the current tree and git state, and adds a note with the resume time. airlok sessions lists what is there.
A session file holds the plaintext conversation and the real value behind every placeholder, because that is what rehydration on resume needs. That is why the file is written with mode 0600 in a 0700 directory, and why the provider API key is the one thing left out: it is stored blank and re-read from your config on every start.
Long sessions are compacted. Once the last request used more than compact_at (default 0.75) of context_window (default 200k tokens; set it for your model), the next turn first asks the model for a summary of everything except the last keep_recent_turns turns and replaces those older turns with it. The summary request goes through the redactor like every other request. You see a dim compacted: 180k -> 22k tokens line; /compact does it on demand. Token counts come from the provider; when a provider reports none, airlok estimates at chars/4 and /cost says so.
Tools
| Tool | Asks? | What it does |
|---|---|---|
read_file(path, offset?, limit?) | no | Read a text file, optionally a window of lines. Binary files are refused. |
glob(pattern, path?) | no | List files matching a glob, gitignore-aware, at most 500. |
grep(pattern, path?, include?) | no | Regex search returning file:line:text, gitignore-aware, at most 200 lines. |
list_dir(path) | no | Entries with type and size. |
edit_file(path, old, new) | diff | Replace old with new; old must occur exactly once, otherwise the model is told how many matches there were. |
write_file(path, content) | diff | Create or overwrite a file. |
bash(command) | unless allow-listed | Run a shell command in the working directory. |
Tool results over 50 KiB are cut with a marker telling the model to page with read_file's offset and limit.
MCP servers
airlok offers the model tools from external MCP servers, configured the way Claude Code, Cursor, and VS Code configure them. A .mcp.json copied from another project works unchanged:
{
"mcpServers": {
"files": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
"env": { "NODE_ENV": "production" }
},
"docs": {
"type": "http",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer ${DOCS_TOKEN}" }
}
}
}
Three files are read, each winning over the one above it. A server named in more than one takes the highest definition whole, so a local file is a real override rather than a patch.
| Scope | File | For |
|---|---|---|
| user | ~/.config/airlok/mcp.json | servers you want everywhere |
| project | ./.mcp.json | the project's servers, meant to be committed |
| local | ./.airlok/mcp.json | your own overrides, gitignored |
A .mcp.json arrives with a clone, so a project-scoped server starts only once this checkout has been asked about it. On first sight of one, or of a definition that changed since the last answer, airlok lists the servers and the commands they would run and asks once. The answer is recorded in .airlok/, per repository and gitignored. Until then the server shows as pending in airlok mcp list and nothing is started, and listing never counts as approving. airlok mcp reset-project-choices forgets the answer so the next run asks again. Servers from your own user file, your local file, or a [[mcp]] block are not gated: you wrote those.
${VAR} and ${VAR:-default} expand from the environment in command, args, env, url, and headers. A variable that is unset or empty with no default is an error naming it, rather than an empty string that fails later in a way nobody can read. For a secret, prefer env_cmd and header_cmd, which take the value from a command's stdout: nothing is written in the file, and nothing has to sit in your environment where every other process can read it.
Managing them:
| Command | What it does |
|---|---|
airlok mcp list | every server, its scope, whether it answers, and its tools |
airlok mcp add <name> --scope user -- npx -y server-filesystem . | write a stdio server; --url with --transport http for an http one |
airlok mcp add-json <name> '<json>' | write a server from an entry pasted as JSON |
airlok mcp reset-project-choices | forget whether this repository's own .mcp.json servers may start |
airlok mcp get <name> | the resolved entry and which file it came from |
airlok mcp remove <name> [--scope ...] | take it out again |
airlok mcp import <path> | merge another tool's mcpServers file into a scope |
airlok mcp export [--scope ...] | print standard JSON, to hand to another tool |
airlok mcp call <server> <tool> '<json>' | call one tool through the same gates, for debugging |
airlok mcp trust list and trust revoke <server> | approvals remembered past a run |
/mcp | in a session, the same list; /mcp <name> enables a disabled server for that session |
airlok's own options
Everything airlok adds lives under an airlok key, so a plain config stays plain:
{
"mcpServers": {
"docs": {
"type": "http",
"url": "https://example.com/mcp",
"airlok": {
"trust": "prompt",
"rehydrate": false,
"tools": ["search"],
"header_cmd": { "Authorization": "printf 'Bearer %s' $(cat ~/.docs-token)" }
}
}
}
}
The same options are available as [[mcp]] blocks in config.toml or airlok.toml, which keep working and win a name clash with any JSON file:
[[mcp]]
name = "files"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "."]
trust = "prompt" # "prompt" (default), "allow", "deny"
rehydrate = false # the default
tools = "all" # or a list: ["read_text_file"]
timeout_secs = 30
enabled = true
What a server may and may not do
Servers start on the first turn that can use them, not when airlok starts. One that fails to start prints a line and is skipped, and the run continues without it. Tools reach the model as mcp__<server>__<tool>; a built-in keeps its name if a server ever claims one.
trust decides the gate. The default, prompt, shows the server, the tool, what the server can reach, the place each argument names, and the arguments themselves, then asks with [y]es / [n]o / [a]ll / [s]ave / [q]uit. a covers that tool on that server for the rest of the run, for the places that call named and no others. s remembers the same approval past the run in .airlok/mcp-trust.json, which is gitignored and written 0600; it records what the server was, so changing its command or url makes it ask again. allow never asks, and deny keeps a server's tools from the model while airlok mcp list still shows them.
rehydrate decides what the server receives. By default airlok sends placeholders: a secret found in your files leaves as <<SECRET_1>> rather than as the value, and the confirmation shows you exactly what will be sent. Set rehydrate = true for a server that genuinely needs the value. The provider API key is refused either way.
A server is untrusted input. Its tool descriptions and its results reach the model inside markers saying they are data from that server, so a description reading "ignore previous instructions" is quoted text and nothing more. Nothing a server sends changes the deny list, the confirmations, or anything else about how airlok behaves.
Plan mode offers no MCP tools, just as it offers no write tools, and it starts no servers.
Context and AIRLOK.md
Every run starts with a context block in the system prompt: the working directory, OS and shell, the git branch with git status --short and the last five commit subjects, a gitignore-aware file tree (four levels, at most 200 entries), and project instructions. Instructions come from AIRLOK.md in the repository root, or CLAUDE.md or AGENTS.md if there is no AIRLOK.md, followed by ~/.config/airlok/AIRLOK.md. The block goes through the redactor like everything else, and airlok context prints exactly what would be sent.
A short AIRLOK.md:
# airlok instructions
- Run `cargo test --workspace` before saying a change is done.
- Never touch files under `vendor/`.
- Commit messages: imperative subject, no trailing period.
[context] max_bytes (default 32768) caps the block; the tree is cut to top-level directories first, then the instructions are truncated.
Configuration
Precedence, highest first: CLI flags, ./airlok.toml in the working directory, the user file, built-in defaults. The user file is $XDG_CONFIG_HOME/airlok/config.toml, which is ~/.config/airlok/config.toml on macOS and Linux. airlok config init writes it with every key commented out.
Full schema with defaults:
[provider]
name = "anthropic" # "anthropic" or "openai"
model = "claude-sonnet-4-6" # per provider: anthropic "claude-sonnet-4-6", openai "gpt-5.5"
# base_url = "https://api.openai.com/v1" # openai only; Azure: "https://<resource>.openai.azure.com/openai/v1"
# api_key_env = "ANTHROPIC_API_KEY" # env var holding the key
# api_key_cmd = "..." # shell command whose stdout is the key
# api = "chat" # openai only: "chat" (the default) or "responses"
context_window = 200000 # input tokens the model accepts; used to decide when to compact
[agent]
max_turns = 50 # model round-trips per run
max_tokens = 8192 # output tokens per model reply
bash_timeout_secs = 120 # kill a bash tool command after this long
compact_at = 0.75 # summarise the session once a request uses this fraction of context_window
keep_recent_turns = 4 # turns kept verbatim after the summary
[safety]
confirm_writes = true # show a diff and ask before write_file / edit_file
confirm_bash = true # ask before running a command that is not allow-listed
confirm_images = true # ask before sending an image, which cannot be scanned for secrets
bash_allowlist = ["git status", "git diff", "ls", "cat", "pwd", "find", "grep", "rg", "cargo check", "cargo test", "cargo build"]
bash_denylist = ["rm -rf", "git push --force", "sudo"]
[context]
max_bytes = 32768 # cap on the context block; tree is cut first, then instructions
[redact]
show_secrets_in_output = false # show secrets from files in full in the terminal instead of masked
[models."gpt-6-astra"] # settings for one model id (the deployment name on Azure); none by default
reasoning_effort = "none" # openai only, sent as reasoning_effort; not validated
api = "responses" # openai only, overrides [provider] api for this model
vision = false # the model takes no images, so airlok refuses at attach time
For openai, base_url falls back to the OPENAI_BASE_URL environment variable when the config does not set it.
Azure OpenAI
[provider]
name = "openai"
model = "<deployment-name>"
base_url = "https://<resource>.openai.azure.com/openai/v1"
api_key_cmd = "az cognitiveservices account keys list -n <resource> -g <resource-group> --query key1 -o tsv"
Azure's gpt-6-astra accepts tools on Chat Completions only with reasoning off, so on that API it needs [models."gpt-6-astra"] with reasoning_effort = "none". Giving the model api = "responses" instead keeps both the tools and the reasoning effort, which is what makes /effort worth using there. With neither, airlok shows the provider's error and names the setting behind it. The entry applies however the model is chosen, /model gpt-6-astra included.
With this in the user config, plain airlok "..." works. The key is sent in Azure's api-key header, chosen from the host. No shell wrapper or exported variable is needed.
Redaction: what comes back, and what never does
Every placeholder belongs to one of two classes, listed by airlok redactions. Secrets found in your files and in tool output are rehydrate: the real value is restored into files and commands, so edits keep working, and shown masked in the terminal (first four characters and the length) unless [redact] show_secrets_in_output = true. The provider API key is redact-only: it is never restored anywhere. If the model echoes its placeholder it prints as [redacted: the provider API key], and a tool call carrying it is refused with a message to the model. --show-redactions lists each placeholder's kind, length, and class, never the value.
The key is read from exactly one place. api_key_cmd wins if set, then api_key_env, then the provider default: ANTHROPIC_API_KEY for anthropic, and AZURE_OPENAI_API_KEY then OPENAI_API_KEY for openai. A command runs once per process and its output is never logged. airlok config show prints the env var name or the command, never the value. Whatever key is in use is also added to the redactor, so it can never leave the machine inside a file or command output either.
Allow-list entries match on leading tokens (git status allows git status --short, not git push). Deny-list entries match anywhere, in every part of a chained command, and win over the allow list. Flags are parsed rather than compared as text: the rm -rf entry also catches rm -fr, rm -r -f, rm -Rf, and rm --recursive --force, and git push --force also catches git push -f. A command with shell operators (;, &&, |, >, $(, and so on) always asks, whatever its first word.