A locally-buildable, extensible coding agent — a single static Go binary (Linux +
macOS), no runtime dependencies. Klaudia began as a cleanroom of Claude Code
(v2.1.66) and was ported to Go; the JavaScript reference is retired (preserved on
the js-reference branch). See Background for the story and
docs/parity.md for the feature map.
Klaudia keeps full parity with the reference and then builds past it — the extras we lean on day to day:
- Code intelligence (LSP) — real diagnostics and go-to-definition from language servers you already have installed.
- Memory & project knowledge — a
MEMORY.mdindex that links out to detail notes, recalled into every session. - Themes — the whole UI (banner, prompts, menus, Markdown) recolors, persisted in config.
- Standing goals —
/goalpins an objective that's re-stated to the model every turn. - Editor integration (ACP) —
--input-format acpmakes Klaudia the agent behind Zed,acp.nvimor the JetBrains plugin. - Isolated sub-agents — children that write get their own git checkout, so two of them can work at once.
- Plus OS/container Bash sandboxing, local web search & browsing, MCP, and skills.
Requires Go 1.26 or newer (go.mod says go 1.26; older toolchains stop
with a version error or try to download a newer one). Check with go version.
go install github.com/greenthread-ai/klaudia/cmd/klaudia@mainThis installs the klaudia binary into $(go env GOPATH)/bin (commonly
~/go/bin) — make sure that's on your PATH. We track main while a release
tag isn't published yet; @main always resolves to the current HEAD, whereas
@latest (the usual Go default) routes through proxy.golang.org and can lag
behind new commits on an untagged module. To force a refresh:
GOPROXY=direct go install github.com/greenthread-ai/klaudia/cmd/klaudia@latest.
Prefer to build from a checkout? See Build.
Klaudia reads ~/.klaudia/config.toml automatically for every run; a project
./.klaudia/config.toml overlays it when present. KLAUDIA_CONFIG_DIR replaces
~/.klaudia as the user directory — config, .mcp.json, skills, sessions, job
logs and the browser profile all move with it. Generate a commented starter:
klaudia --create-config=global # ~/.klaudia/config.toml (your default; honours KLAUDIA_CONFIG_DIR)
# or:
klaudia --create-config=local # ./.klaudia/config.toml (project override)The starter selects the Anthropic provider, so it works as written with
ANTHROPIC_API_KEY set; the OpenAI-compatible settings are in it, commented
out. Both commands refuse to overwrite an existing config (so you can't
accidentally clobber settings); delete the file first if you want a fresh
starter.
A project config arrives with the checkout, so in a folder you haven't trusted
Klaudia ignores its security-relevant keys and says which ones: provider,
baseURL, apiKey, apiKeyEnv, extraHeadersEnv, permissions.mode,
permissions.allow, trust.mode, the sandbox settings other than readOnly,
and browser.chromePath/remoteUrl/userDataDir. Preferences (model, theme,
limits) and deny rules always apply. Run klaudia --trust-project in the folder
to apply the whole file; --create-config=local trusts the folder it writes to.
The list is ~/.klaudia/trusted-projects, one directory per line. A launcher
that renders ./.klaudia/config.toml itself (an embedder writing a per-session
config) passes --trusted-project-config to apply it in full for that run.
klaudia --safe-mode starts without anything the project supplies — its
.klaudia/config.toml, .mcp.json servers, skills, CLAUDE.md, memory and
knowledge — for opening an unfamiliar repository or getting past a broken
project config. Your own config, global MCP servers and skills still load.
Pick one of these paths:
Anthropic API key
export ANTHROPIC_API_KEY="sk-ant-..."
klaudiaExisting Claude Code login on macOS
Klaudia can reuse an existing Claude Code OAuth session from the macOS Keychain. Sign in with Claude Code first, then run Klaudia:
claude
klaudiaOpenAI-compatible provider
In the config you just generated, replace provider = "anthropic" with the
commented OpenAI-compatible block (delete the leading # from each line) and
fill in your endpoint:
# ~/.klaudia/config.toml (comments are supported)
provider = "openai"
# The model id is sent to the endpoint exactly as written: use the id the
# endpoint lists (GET <baseURL>/models, or /model in the TUI). Do not prefix it
# with the provider name — "openai/gpt-5.5" is a different id from "gpt-5.5",
# and only hosts whose own ids carry a prefix (OpenRouter) want one.
model = "gpt-5.5"
baseURL = "https://api.example.com/v1"
# apiKeyEnv is the NAME of the environment variable that holds your key —
# pick any name you like and export a variable of that name (see below).
# Prefer this over apiKey = "sk-..." so the key stays out of the file.
apiKeyEnv = "MY_API_KEY"
# extraHeadersEnv adds HTTP headers to every request, each read from the NAMED env
# var (never a value in the file). Use it for endpoints gated by non-bearer headers
# — e.g. a Cloudflare Access service token — with or without apiKeyEnv:
extraHeadersEnv = { "CF-Access-Client-Id" = "CF_ID", "CF-Access-Client-Secret" = "CF_SECRET" }Then export the variable you named in apiKeyEnv and run:
export MY_API_KEY="sk-..." # same name as apiKeyEnv above
klaudiaOnce the TUI starts, type /doctor to verify auth and environment status.
Pure Go, no CGO, no system libraries. Needs Go 1.26+ (the go line in
go.mod):
CGO_ENABLED=0 go install ./cmd/klaudia # or: go build -o klaudia ./cmd/klaudia
make check # static checks, unit (race), hermetic, and e2e — what CI runsThe result is one self-contained binary (Linux + macOS).
klaudia --version prints the reference-compatible 2.1.66-klaudia (Klaudia)
and then the build: the commit it was built from, +dirty if the tree had
uncommitted changes, and the commit time. The startup banner, /doctor and each
transcript line (klaudiaBuild) carry the same, so a stale installed binary is
visible. go build/go install in a checkout record this automatically; a
build without VCS information (a source tarball, -buildvcs=false) shows
dev, and a release build can stamp it:
go build -ldflags "-X github.com/greenthread-ai/klaudia/internal/version.release=v1.2.3 \
-X github.com/greenthread-ai/klaudia/internal/version.commit=$(git rev-parse HEAD)" ./cmd/klaudiaTesting is layered; see docs/testing.md. go test ./e2e/...
drives the real binary against a scripted model — no credential, no network.
Two end-to-end rigs go beyond the unit tests (both need a working credential):
scripts/smoke.sh drives the real agent loop across modes, the client-side
tools and the resume path on haiku; scripts/torture.sh runs the spec's
agent-loop torture test — one task needing 20+ file inspections, edits, a dev
server, log inspection, SSH to a container, a wrong turn and a recovery — and
scores the transcript against the spec's checklist.
./klaudiaA Bubble Tea terminal UI: streamed Markdown answers, / slash commands with
type-ahead and Tab completion of their arguments (/theme, /mode, /model
from its last-fetched list, job names, /last numbers, pinned files, /trust revoke ids), fuzzy @path file completion (Tab, Tab again to cycle), input
history (↑/↓, and Ctrl+R to search it), and Esc to interrupt a turn. Type
/help for the full list.
Input history is kept per project, the last 200 prompts, in
~/.klaudia/sessions/<project>/prompt-history.ndjson — beside the transcripts
and outside the repository, so it cannot be committed by accident. A !
command is remembered as the line you typed, never its output; a prompt holding
a paste chip, or over 8 KiB, stays in the session's history only. Resuming a
session also puts its prompts back under ↑. In a multi-line prompt ↑ and ↓ move
between lines and browse history from the first and last line. Ctrl+R searches
backwards as you type: Ctrl+R again for an older match, Enter to put the
match in the box to edit, Esc to cancel.
A line that starts with / but is not a command is sent as a message when its
first word is a path (/etc/nginx/nginx.conf fails to parse) or is followed by
more text. A lone unknown /word is reported with the nearest commands. Start a
line with // to always send it as a message, minus one slash.
Return sends; Ctrl+J and Alt+Return insert a newline. To swap them:
[input]
enter = "newline" # Return inserts a newline; Alt+Return or Ctrl+J sendsCtrl+Return is not something Klaudia can offer on its own. A terminal sends
the same byte for Return and Ctrl+Return (CR, 0x0D) — Ctrl+J is simply the
LF byte, which is why it is the traditional newline chord. Terminals that
implement the Kitty keyboard protocol can distinguish the two, but Bubble Tea v1
does not parse those sequences, and enabling the protocol would break every
other Ctrl binding.
What does work is telling your terminal to send ESC CR for the chord, which
arrives as Alt+Return:
| Terminal | Setting | Caveat |
|---|---|---|
| Ghostty | keybind = ctrl+enter=text:\x1b\r |
— |
| kitty | map ctrl+enter send_text all \x1b\r |
— |
| WezTerm | { key="Enter", mods="CTRL", action=wezterm.action.SendString("\x1b\r") } |
— |
| iTerm2 | Settings → Keys → Key Bindings → ⌃↩ → Send Escape Sequence → \r |
— |
| Apple Terminal | Cannot remap Return at all |
Use Option+Return, with Settings → Profiles → Keyboard → Use Option as Meta key. That option also stops Option from typing é, © and friends |
| Windows Terminal | { "keys": "ctrl+enter", "command": { "action": "sendInput", "input": "\u001b\r" } } |
Alt+Return is the fullscreen toggle by default and never reaches the app, so remap it or use Ctrl+J |
| tmux / screen | nothing to do — ESC CR passes through |
— |
Ctrl+J works in every terminal, in both modes, needing no configuration at
all: it is the LF byte. That is the escape hatch, and it is why
enter = "newline" cannot leave you unable to send.
What this changed: Alt+Return used to send, because the Return handler
ignored the Alt modifier. It now inserts a newline (or sends, in newline
mode). Two consequences worth knowing: if you were using Option+Return to
send on macOS, that now adds a line; and pressing Esc immediately followed by
Return can be read as Alt+Return, since that is the same byte sequence —
one of the reasons a terminal cannot simply invent a Ctrl+Return key.
When Klaudia needs you while you are looking elsewhere — a turn has finished, or a permission/approval prompt is waiting — it can get your attention through the terminal:
[tui]
notify = "bell" # default when unset: the terminal bell (\a)
# notify = "bell,osc9" # bell + an iTerm2/kitty/WezTerm desktop notification
# notify = "osc777" # an rxvt/urxvt desktop notification
# notify = "all" # every mechanism
# notify = "off" # stay silentThe mechanisms are bell (the terminal bell, the most widely supported),
osc9 (an OSC 9 desktop notification honoured by iTerm2, kitty and WezTerm) and
osc777 (an OSC 777 notification for rxvt/urxvt and others); combine them with
commas. When your terminal reports focus (Klaudia enables focus reporting, which
does not disturb scrollback or selection), the notification fires only while the
window is unfocused; a terminal that does not report focus is notified either
way.
An idle prompt writes nothing to the terminal: the cursor is steady and no timer
runs while Klaudia waits, so a multiplexer, a remote viewer or a program driving
Klaudia in a pseudo-terminal sees output stop when the work does. Klaudia also
sets the terminal title to its state — klaudia: ready, klaudia: working,
klaudia: awaiting approval, klaudia: goal-loop — each time the state
changes (docs/embedding.md).
[tui]
cursor = "steady" # default; "blink" repaints the input twice a second while idle
title = "on" # default; "off" leaves the terminal title aloneKLAUDIA_CURSOR_BLINK=1 (or 0) overrides cursor for one run.
Klaudia renders inline, not full-screen. Finished output is printed into
your terminal's real scrollback and only the input and status bar are redrawn in
place, so scrolling, drag-to-select, your terminal's own search and tmux copy
mode all keep working — and the conversation is still there after you quit.
Copying is meant to be exact: rendered code blocks carry no margin, no padding
and no expanded tabs, so a snippet pastes as the source it came from. /copy
puts the last answer, a code block, or a tool result on the system clipboard via
OSC 52, which works over SSH and inside tmux.
Ctrl+C does the smallest useful thing first — interrupt a running turn, cancel
a prompt, or clear the line — and only quits when pressed twice in a row.
Tool calls show their key input (e.g. ⚙ Bash go test ./...) and a -/+
preview for edits; a status bar tracks model · mode · turns · tokens. Long
output is kept in full: the preview tells you its number and /last <n> opens
it in $PAGER, where searching and copying are your pager's job. /search,
/outline and /errors index the session, and /open <path:line> sends a
reference copied from a stack trace straight to $EDITOR.
You can queue a follow-up while the model is working: type and press Enter to
queue it (it's sent when the current turn finishes); press Enter again to
interrupt and send it now, or ↑ to edit it.
/model with no argument asks the provider which models it serves and offers
them as a picker — Anthropic and OpenAI-compatible endpoints both answer at
GET /v1/models — so you don't have to remember an exact model ID. /model <alias|id> still sets one directly (opus, sonnet, haiku, fable on
Anthropic, or any full ID). Picking from the list also records that model's real context window,
which is what the status bar's ctx N% measures against.
/effort <level> sets the reasoning effort for the rest of the session
(low, medium, high, xhigh, max; default returns to the model's own);
with no argument it reports the current level. It applies from the next turn.
Every picker (/model, /mode, /theme, /mcp) works the same way: ↑/↓
move the highlight and Enter picks it, typing filters the list (Backspace edits
the filter), 1–9 pick directly while no filter is typed, and Esc cancels. A
list longer than the terminal scrolls, so /model offers everything the
endpoint serves.
/theme switches the colour theme (Markdown + chrome) for the session; set a
durable default with theme = "nord" in .klaudia/config.toml (dracula |
gruvbox | tokyo-night | nord | light | catppuccin). NO_COLOR is honoured.
Long-running commands run detached as managed jobs: Bash with
run_in_background returns a shell id, BashOutput reads new output
incrementally and KillShell stops it — so the agent can launch a dev server or
watcher and keep working. Jobs get a name, a port and a log file; see
Long-running commands, logs, and your shell.
# Print the final result and exit
./klaudia -p "What files are in this directory?"
# Piped stdin is the prompt, or is appended to one given as an argument
echo "What files are in this directory?" | ./klaudia -p
git diff | ./klaudia -p "Review this change"
# Unattended, including changes to this machine
./klaudia -p "Install and configure nginx" --allow-host-changes
# Stream events as JSON (tool calls, results) as they happen
./klaudia -p "Explain the build" --output-format stream-json --verbose
# Partial message deltas, JS-compatible (only with --print + stream-json)
./klaudia -p "…" --output-format stream-json --verbose --include-partial-messagesA positional prompt is shorthand for -p: ./klaudia "What files are here?"
runs headless and exits, so it scripts the same way. To open the TUI with a first
message instead — a persistent session that starts working straight away, which
is what an orchestrator launching Klaudia wants — use
./klaudia --prompt-interactive "Fix the failing test". The text is sent as if
typed, so slash commands (--prompt-interactive "/goal run 5") and @file
references work, and the session stays open afterwards. (Claude Code opens its TUI
with the prompt instead; see docs/ux-spec.md.)
--max-turns N caps the agentic loop; 0, the default, is unlimited.
With --output-format json or stream-json, stdout always ends with a
result line, even when the run cannot start (no credential, an incomplete
provider config, a bad flag combination): is_error is true and result
carries the reason, which is also printed to stderr. duration_api_ms is the
time spent waiting on the model, duration_ms the whole run. total_cost_usd
is priced from Klaudia's model table and reads 0 for a model it has no price
for (every OpenAI-compatible model) — unpriced, not free. The exit code says what kind of failure it
was — see Exit codes.
./klaudia completion bash > ~/.local/share/bash-completion/completions/klaudia
./klaudia completion zsh|fish|powershell --help # install steps per shellA persistent agent driven by newline-delimited JSON over stdin/stdout — the
channel for SDK integrations, orchestrators, and an editor without ACP support
(no terminal needed). The full, versioned contract — which lines and fields are
stable, the session lifecycle, and klaudia --capabilities for feature
detection — is docs/embedding.md; this is the overview:
./klaudia --input-format stream-json --verboseEach {"type":"user","message":{"role":"user","content":"…"}} line is one
turn. Conversation content streams back as the same message envelope the -p --output-format stream-json path (and Claude Code) emit — one line per
assistant message and per tool-result message, with session_id and uuid:
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"…"},
{"type":"tool_use","id":"…","name":"Read","input":{…}}]},"session_id":"…","uuid":"…"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"…",
"content":"…"}]},"session_id":"…","uuid":"…"}usage, tool_progress and compaction events follow as flat lines, and the
turn ends with a result line.
Permission asks are the client's to answer. When the permission flow cannot settle a tool call on its own, Klaudia emits a control request and blocks the turn until the client replies:
{"type":"control_request","request_id":"<id>",
"request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"ls"}}}The reply carries the same request_id; behavior is "allow" or "deny",
and a deny may explain itself in message:
{"type":"control_response","response":{"subtype":"success","request_id":"<id>",
"response":{"behavior":"deny","message":"read-only embedder"}}}Only a call nothing else can settle reaches the client. In autonomous (the
default) project work runs without asking, so what is asked about is a change
to this machine — a package install, a write outside the project — and in
bypassPermissions nothing is asked at all. There are no allow/deny rules:
the per-command model was removed upstream (see docs/trust.md).
An ask the client never answers is denied after --ask-timeout (default
10 minutes; 0 waits forever), with a tool result that says so — a stalled or
protocol-unaware client sees a finished turn, not a hung process.
Permission asks, the model's questions (AskUserQuestion) and plan approvals
(ExitPlanMode) all arrive as control_request lines and are answered on the
matching request_id. A peer that has not implemented a subtype should answer
with an error: that reads as "cancelled" and leaves the model where it was,
rather than an answer the user never gave being attributed to them.
The client can steer the session with its own control requests, in Claude
Code's shapes. Each is answered with a control_response carrying the same
request_id — "subtype":"success" with a response object, or
"subtype":"error" with an error message:
{"type":"control_request","request_id":"r1","request":{"subtype":"interrupt"}}
{"type":"control_response","response":{"subtype":"success","request_id":"r1","response":{}}}interruptcancels the running turn (what Esc does in the TUI), including acan_use_toolask it is waiting on, and any turn sent before the interrupt that has not started. Each such turn still ends with aresultline, with"subtype":"error_during_execution"and a result saying it was interrupted.set_permission_mode("mode":"plan", …) applies from the next tool call, sub-agents included. The rules are the command line's:autonomousneeds the host gate enforcing, andbypassPermissionsis refused unless the session was launched in it.set_model("model":"opus"; omitted or"default"restores the launch model) applies from the next turn.initializeis answered once. One that asks forhooks,sdkMcpServers,agents,jsonSchemaor a system prompt is refused — Klaudia cannot run an SDK's callbacks, and saying so beats a hook that silently never fires.
The result line carries session_id and duration_ms alongside usage.
./klaudia --input-format acpKlaudia serves the Agent Client Protocol v1
over stdio, so Zed, Neovim's acp.nvim and the JetBrains plugin can drive it as
their coding agent — the editor's own UI for messages, tool calls, diffs,
checklists and permission prompts, Klaudia doing the work. In Zed
(~/.config/zed/settings.json):
{
"agent_servers": {
"Klaudia": {
"type": "custom",
"command": "klaudia",
"args": ["--input-format", "acp"]
}
}
}Sessions, modes and commands map onto what Klaudia already has: ACP session ids
are transcript ids (so --resume and the editor's thread list agree), the
mode picker is /mode and is per thread, TodoWrite becomes a native plan,
Edit and Write carry diffs, and .klaudia/skills appear in the command
palette. fs/read_text_file is used when the editor offers it, so the model
reads your unsaved buffer rather than stale text on disk.
Writing through the client, terminal/* and session/delete are declined on
purpose — the reasoning, and the rest of the surface, is in
docs/acp.md.
./klaudia # auto-resume the most recent session here
./klaudia --new-session # start fresh instead of auto-resuming
./klaudia --continue # explicitly resume the most recent session here (-c)
./klaudia -r <session-id> # resume a specific session
./klaudia -r <session-id> --full # replay the whole transcript (not the summary)
./klaudia --session-id <id> … # record under an id you choose (must be new)
./klaudia -r <old> --session-id <new> # fork <old> into a new session <new>Auto-resume is an interactive convenience: headless (-p) and the embedding
protocols (--input-format stream-json and acp) stay stateless unless you
pass --continue or -r <id>. An ACP client has session/list and
session/load for the same job.
Auto-resume always says what it did, in one line before the TUI starts:
Resumed <id> · 42 messages · last active 3h ago · --new-session to start fresh. A session last active longer ago than the staleness cutoff is not
auto-resumed; the launch starts fresh and names the old session and how to
resume it (--continue or -r <id>, which ignore the cutoff). The cutoff
defaults to 24 hours:
[session]
autoResumeMaxAge = "24h" # a Go duration or whole days ("7d"); "0" disables the cutoff/clear starts a new session id. The cleared conversation stays on disk as its
own session (/clear prints the -r <id> that reopens it) and is not what the
next launch auto-resumes — not even when you clear and quit straight away.
Sessions are JSONL transcripts under ~/.klaudia/sessions/<encoded-root>/
(override the base with KLAUDIA_CONFIG_DIR), where the root is the project
root: the git top-level of the launch directory, or the launch directory itself
outside a repository. Starting Klaudia in a subdirectory therefore resumes the
repository's session. The launch directory's own dir (where sessions went
before they were keyed by the root) is still read by auto-resume and
--continue, and Klaudia still reads legacy transcripts from
~/.klaudia/projects/<encoded-dir>/ during migration. When a session has a
persisted compaction summary, resume seeds from it plus the messages recorded
since that compaction (token-saving) unless --full.
-r <id> finds the transcript by id anywhere under the sessions root, not only
in the current project's dir, and keeps appending to the file it was
found in. An embedder can therefore pin the id with --session-id, stop the
process, move the sessions root (for example to another host, pointed at with
KLAUDIA_CONFIG_DIR) and resume from a different working directory. A session
id is letters, digits, - and _ (at most 128). In embedding mode the first
output line is {"type":"system","subtype":"init","session_id":…,"resumed":…, "history_messages":…}, so the peer learns the id before it sends a turn.
npm run dev becomes a job rather than a blocked turn: it keeps running,
gets a name and a log file, and Klaudia carries on. /jobs lists what is up and
on what port, /logs <job> opens the log in your $PAGER, /logs -f tails it
into real scrollback, /restart replaces the process in place rather than
starting a second copy, and a crash is reported when it happens.
Commands run in their own process group, so stopping one stops what it started —
and they inherit your PATH, ssh agent and git credential helpers. Klaudia does
not allocate a PTY, so vim, less, top and git commit with no -m are
refused immediately with the flag that would have worked, rather than hanging.
You can type while Klaudia works and it will read your message before its next
step, not after the turn; /stop asks it to finish the current step and report.
A leading ! runs a command yourself, and its output becomes context:
> work out why the auth test is failing
$ git diff
> keep the test change but revert the API change
Details, including what deliberately does not work: docs/jobs.md.
Klaudia knows which working-tree changes are yours, which are its own, and which
are both. /changes shows the split, /commit stages only its own work and
lists what it left out, and /undo restores its last change — never touching a
file you also edited.
Undo stores prior contents as git blobs (git hash-object -w). It does not
touch your index, does not create a stash, and shows the exact git cat-file
commands it would run before doing anything. Full detail:
docs/working-tree.md.
Sub-agents that can write get a checkout of their own. Two children editing
one tree is not a race in the usual sense — nothing errors, and every step
succeeds: one writes a file, another reads a half-written version and reasons
about it, a third rewrites the first one's edit. So a sub-agent holding Write,
Edit, NotebookEdit or Bash runs in its own git worktree under
~/.klaudia/worktrees/, seeded with your uncommitted work rather than HEAD,
and its changes are applied back to your tree with git apply when it finishes
— files only, never your index. Explore and Plan keep sharing the tree;
they have nothing to isolate. Anything that could not be applied because the
file moved underneath is named to you and to the model, and the checkout is
kept so you still have that version. Ignored files are not copied, so a child
that needs an install step before it can test will pay for it — turn the whole
thing off with [subagents] worktree = false if that is the wrong trade for
your project.
/context shows what Klaudia has actually read rather than a token percentage,
and /pin <path> keeps a file in context every turn so it survives compaction.
Headless runs exit with codes an automation can branch on — notably 4 for "needed a host change and had no way to ask".
The terminal-UX specs this was built against, and the places the implementation deliberately went a different way, are recorded in docs/ux-spec.md.
Klaudia finishes the task without asking per action, and stops before changing
the machine it runs on. Work in the project — editing, building, testing, git,
dev servers, and the destructive parts like rm -rf ./dist — is autonomous, as
is work on a remote host the task calls for. Changing this machine (packages,
services, /etc, shell rc files, users, firewall) needs your agreement, and
Klaudia asks for the whole operation at once rather than one command at a time:
This changes your machine
Install nginx and configure it as a development proxy
why: the task asks for the app to run behind a local proxy
paths: /etc/nginx services: nginx packages: nginx
approving covers every step inside that scope, for this session only
Approvals are session-scoped and never written to disk. /trust shows what is
live and revokes it.
Most gate hits never reach you. An incidental 2>/dev/null or a scratch file in
/tmp is stopped, Klaudia takes another route, and the attempt is drawn quietly
as ⊘ changes this machine: writes /dev/null — trying another way rather than as
a failure. You are asked only when the work genuinely cannot proceed otherwise,
and then (s)omething else sits beside yes and no — declining usually means
"not like that" rather than "give up", so it keeps the turn alive and lets you
redirect. Every other permission ask offers it too, and each prompt notes that
Esc cancels the whole turn. Anything blocked and never approved is named in the
completion block under Not done — needs your agreement, so giving up quietly
is not an option available to it.
This is a guardrail against well-intentioned mistakes, not a security
boundary. It reads command lines and tool inputs; it does not watch what
programs do, so a command that builds its own target or a package's install
script can change things without being seen. For enforcement the kernel applies,
set [sandbox] mode = "os".
| Flag | Mode | Behavior |
|---|---|---|
| (default) | autonomous |
Finish the task; ask before changing this machine |
--permission-mode plan |
plan |
Read-only; mutations and network blocked |
--dangerously-skip-permissions |
bypassPermissions |
Allow everything, including host changes |
/mode switches interactively; /trust shows and revokes approvals. Headless
runs do project and remote work but refuse host changes unless you pass
--allow-host-changes.
The per-command model it replaced is removed. There are no allow/deny
rules, no --allowedTools/--disallowedTools, no /allow or /deny, no
default/acceptEdits/dontAsk modes, and no "always" answer on a prompt;
[permissions] allow/deny and [trust] in .klaudia/config.toml are no
longer read. /trust grants by what an operation does rather than by
matching command text, and an approval is session-scoped.
Running both models at once was the problem: a rule in a project config demoted the next session to a mode that asked before every edit, so "always" — the answer offered to stop a prompt — was what produced more of them.
Fork note: default, acceptEdits and dontAsk are still accepted as
deprecated aliases for autonomous (with a notice), so launchers built against
the old names keep starting; they select the mode and nothing else.
Full detail, including the zone table and what is deliberately not protected: docs/trust.md.
Klaudia defaults to the Anthropic Messages API. A project or user
.klaudia/config.toml selects the provider and model:
# "anthropic" (default) | "openai"
provider = "openai"
# Sent exactly as written — the id the endpoint lists, with no provider prefix
# unless the endpoint's own ids have one (OpenRouter's "openai/gpt-5").
model = "gpt-5.5"
# OpenAI-compatible endpoint.
baseURL = "https://api.example.com/v1"
# apiKeyEnv names the env var holding the key (you then `export MY_API_KEY=...`).
# Or set apiKey = "sk-..." inline — but the env form keeps secrets out of files.
apiKeyEnv = "MY_API_KEY"
# extraHeadersEnv maps a header name -> the NAME of an env var holding its value.
# Applied to every request (alongside Authorization when a key is set); for endpoints
# gated by non-bearer headers such as a Cloudflare Access service token. With no
# apiKey/apiKeyEnv, the endpoint is authenticated by these headers alone.
extraHeadersEnv = { "CF-Access-Client-Id" = "CF_ID", "CF-Access-Client-Secret" = "CF_SECRET" }
# Optional: set the model's context window in tokens so autocompaction kicks
# in before the provider overflows. Defaults to 200000 (Anthropic-sized); set
# this when running against smaller-context models (e.g. 128000 for many
# OpenAI-compatible hosts) — otherwise long sessions can hit
# "max_tokens must be at least 1, got -N" or "context length exceeded" 400s.
# contextWindow = 128000
# Optional: cap the tokens a single turn may generate. On Anthropic it defaults
# to the model's real maximum (128000 on the 1M-context Claude models, 64000 on
# the 200k ones, 8192 for models Klaudia doesn't recognise); with
# provider = "openai" it is always 8192. Set this when your provider's limit
# differs from that fallback.
# maxTokens = 32000
# Optional: reasoning effort (low | medium | high | xhigh | max). Unset sends
# none and the model uses its own default. --effort and /effort override it.
# Anthropic receives it as output_config.effort; OpenAI-compatible endpoints as
# reasoning_effort, with xhigh and max sent as high. A level the model doesn't
# support is lowered to its highest (or dropped, on Haiku 4.5 / Sonnet 4.5).
# effort = "xhigh"
# Optional (Anthropic): "adaptive" or "disabled". Unset sends no thinking
# parameter, so each model runs its default — adaptive on Opus 5, Sonnet 5 and
# Fable, none on Opus 4.8 and older. "disabled" is ignored where the model
# cannot turn thinking off (Fable, Opus 5.5, Opus 5 at xhigh/max).
# thinking = "adaptive"
# Optional: a model to fall back to. When the model is overloaded (529/503,
# after the usual retries) the request is retried once on this model; when the
# provider says the model does not exist, the rest of the session uses this
# model. Unset, either error ends the turn. --fallback-model overrides it.
# fallbackModel = "sonnet"
# Optional: what the Return key does at the prompt. "send" (default) submits
# and ctrl+j / alt+Return insert a newline; "newline" swaps them. See
# "Return, and multi-line input" above — ctrl+Return is not a value, because
# terminals cannot send one.
# [input]
# enter = "newline"
# Optional: how Klaudia gets your attention when it needs you (a turn finished,
# a prompt is waiting). A comma-separated list of "bell" (terminal bell), "osc9"
# (iTerm2/kitty/WezTerm desktop notification) and "osc777" (rxvt/urxvt); "all"
# enables every mechanism and "off" disables them. Defaults to "bell". See
# "Attention notifications" above.
# [tui]
# notify = "bell,osc9"
# cursor = "blink" # default "steady": a blinking cursor never lets the terminal go quiet
# title = "off" # default "on": the terminal title tracks ready/working/awaiting approvalCreate a commented starter config with ./klaudia --create-config=global for
~/.klaudia/config.toml, or ./klaudia --create-config=local for
./.klaudia/config.toml.
--model haiku|sonnet|opus (or a full model ID) overrides per-run, as --effort
does for effort, and --fallback-model sets the fallback the same way. A
switch to the fallback is announced in the TUI, and on stderr (note: …) in
-p runs. It is never made once part of the reply has been shown — a second
request would show it twice — and it covers sub-agents and compaction summaries
too, since they go through the same provider. The aliases and the Claude default
model belong to the Anthropic provider: with provider = "openai" the model
string is sent exactly as written (a bare sonnet gets a warning, not a
rewrite), and a model is required. The
OpenAI-compatible provider translates the Anthropic message shape to Chat
Completions (including image tool-results → image_url).
~/.klaudia/config.toml (or $KLAUDIA_CONFIG_DIR/config.toml) is the user
default; a project ./.klaudia/config.toml overlays it (project wins). Settings
merge per field. The provider, endpoint and key settings apply from a project
file only once the folder is trusted (see
Create a config).
A config file that does not parse stops Klaudia before anything runs (exit 2), with the file, line and column. A key Klaudia does not know — a typo, or a setting from a newer or older version — is skipped with a warning naming the file and line; the rest of the file still applies.
ANTHROPIC_API_KEY(orANTHROPIC_AUTH_TOKEN), or- an existing Claude Code OAuth session in the macOS Keychain (Klaudia refreshes expired tokens and writes them back), or
- a provider key via
apiKey/apiKeyEnvin.klaudia/config.toml.
KLAUDIA_STREAM_IDLE_TIMEOUT— seconds a streamed model turn may go without any event before it's treated as a stalled connection (default120). On a stall Klaudia transparently retries the turn if nothing has been emitted yet, otherwise it fails the turn with a clear timeout instead of hanging forever. Set to0to disable the watchdog.- Long-context credits (429) — if the API returns "Usage credits are
required for long context requests", that's a billing/entitlement gate, not a
transient throttle: retries won't help. Add usage credits, or reduce context
(lower
contextWindowso autocompaction triggers earlier, and/compact).
The TUI paints inline and coordinates every write it makes; anything else writing to the terminal lands mid-repaint and tears the frame (a stray library log once spliced the input box's border into the status line). Klaudia therefore keeps other writers off the terminal without going deaf:
- Chrome's CDP chatter is split by cause. chromedp logs any DOM/Page event newer
than its own type switch as an error — structural, since that switch trails
the protocol, and noisy enough that an ad-carrying page emits one per update —
so that class is dropped. Everything else it reports is kept and attached to
the error of the next browser operation that fails, as
(chrome: …). - The standard logger is pointed at
io.Discardwhile the program runs, as a backstop for the next dependency that reaches forlog.Printf.
Two env vars recover the raw output when you're debugging:
KLAUDIA_LOG— file path for anything the process writes via the standardlogpackage (Klaudia's own and its dependencies').KLAUDIA_BROWSER_LOG— file path for chromedp's browser/protocol log, unfiltered, including the dropped events.
Neither is on by default, and a path that can't be opened is dropped rather than
reported — the noise is the thing being prevented. /doctor remains the way to
check auth, tools and environment.
.klaudia/config.toml → sandbox.mode:
local(default) — run on the host, unconfined.os— host confinement:sandbox-exec(macOS) /bubblewrap(Linux). Writes are limited to cwd + temp (+writeRoots);networkconfigurable. Reads are unrestricted except for your credentials —~/.ssh(known_hosts and config stay visible),~/.aws,~/.gnupg,~/.kube/config,~/.netrc,~/.npmrc,~/.docker/config.json, gcloud/azure/gh/1Password config and the like — which commands cannot read. SetreadCredentials = truewhen commands in the sandbox need them (git over ssh, the aws or kubectl CLIs). Falls back to local with a warning if the tool is absent or cannot run (bwrap needs unprivileged user namespaces); setfailIfUnavailable = trueto refuse to start instead.container— run inside docker/podman (runtime,image,mountCwd,readOnly,network).
sandbox.memoryMax (e.g. "4G", "512M"; opt-in, any mode) caps the memory
one Bash command and everything it starts may use, swap included, so a runaway
command is OOM-killed on its own. In container mode it is --memory. On Linux
otherwise it is a cgroup made by systemd-run --user --scope, which needs a
systemd user session with the memory controller delegated (the default on
current distros); Klaudia checks at startup that the limit is really enforced
and warns, running commands without it, when it is not — as it does on macOS.
Built-in, permission-gated tools backed by a lazily-launched headless Chrome (nothing spawns until a web tool runs; the browser is closed at session end):
BrowserSearch— DuckDuckGo (default) or Google; returns titles/URLs/snippets.BrowserFetch/BrowserNavigate/BrowserSnapshot— render a page and return Markdown.
On a Claude model these are the fallback: the built-in Anthropic web_search /
web_fetch server tools are preferred (they return cited results). The
Chrome-backed tools above are what non-Claude providers use, and what you get
when you explicitly ask Klaudia to drive the browser.
Requires a Chrome/Chromium install (auto-discovered; set KLAUDIA_CHROME_PATH
on Linux/Windows if not on PATH). Tunable via .klaudia/config.toml →
browser (engine, headless, chromePath, userDataDir, headedFallback,
…) or KLAUDIA_* env vars. When a search hits a bot-challenge page, Klaudia can
relaunch a headed Chrome with a persistent profile (~/.klaudia/browser/…)
so you can solve it once. Anthropic's server-side web_search/web_fetch betas
remain available when using the Anthropic provider.
Model Context Protocol servers from .mcp.json, read from three scopes in
increasing precedence: global ~/.klaudia/.mcp.json (honours
KLAUDIA_CONFIG_DIR), then the project's .mcp.json, then
.klaudia/.mcp.json. Per server name, the narrower scope wins — a project can
point a globally configured server at a different binary without disturbing it
elsewhere. Put personal servers you want everywhere in the global file, and
servers belonging to a repo in the project's. The two project files come with
the checkout and a stdio server in one is a command Klaudia runs, so their
servers start only in a folder you have trusted (klaudia --trust-project, or
--trusted-project-config from a launcher that wrote the file); elsewhere they
are listed in a warning and not started. Servers start in parallel, each with
30 seconds to answer (KLAUDIA_MCP_CONNECT_TIMEOUT); a tool call gets ten
minutes (KLAUDIA_MCP_TOOL_TIMEOUT, or "timeout" in seconds on the server).
A server is stdio (command +
args) or HTTP (url, with type:"sse" for the legacy SSE transport).
// and /* */ comments are allowed; a file that still doesn't parse is
reported — naming the file, since all three share a base name — rather than
silently loading nothing:
Edits to any of those files apply to the running session: servers are
added, dropped or restarted in place, and a server whose config didn't change
keeps its session rather than being interrupted. Installing a server no longer
means restarting to use it. A config that doesn't parse leaves the running
servers alone, so a half-typed file can't take working tools away; the reload
is otherwise silent, so check /mcp if a server doesn't appear.
Their tools appear as mcp__<server>__<tool>, auto-deferred behind ToolSearch.
In the TUI, /mcp lists servers and reconnects/disconnects them. A server that
announces tools/list_changed has its list re-fetched and folded in live, so a
tool that appears or disappears mid-session needs no restart.
Prompts as slash commands. A server's MCP prompts (prompts/list) become
/mcp__<server>__<prompt> commands in the TUI. Running one fetches the prompt
(prompts/get) and submits its rendered text as the turn. Arguments take
key=value tokens, and bare text fills the prompt's first required argument, so
/mcp__wiki__review the login flow works without ceremony.
OAuth for remote servers. A remote (url) server can authorize with OAuth
2.1 via an oauth block. Secrets are named by environment variable, never
written in the file:
{ "mcpServers": {
"remote": {
"type": "http", "url": "https://mcp.example.com/v1",
"oauth": {
"grant": "client_credentials",
"tokenUrl": "https://auth.example.com/oauth/token",
"clientId": "klaudia",
"clientSecretEnv": "MCP_REMOTE_SECRET",
"scopes": ["mcp"]
}
}
} }The client_credentials grant is complete: Klaudia mints and refreshes tokens on
its own. The authorization_code grant (fields authUrl/redirectUrl) has token
storage and refresh working — a token is persisted under
~/.klaudia/mcp-oauth/<server>.json (0600) and refreshed transparently — but the
interactive browser step that first obtains a token is not yet wired, so until
then such a server connects unauthenticated.
Klaudia speaks protocol 2026-07-28, which lets a server ask you for
something mid-call — a token it has no other way to obtain, a branch name, a
yes/no before something destructive. The question arrives at the same prompt the
model's own AskUserQuestion uses, labelled with the server that asked. Each
field in the server's schema is one question; booleans and enums become choices,
anything else is typed. You can always skip an optional field or cancel the
whole form, and cancelling tells the server you cancelled rather than handing it
a guess.
Two deliberate limits. Only form elicitation is supported, and only form is advertised: the spec's other mode hands the client a URL to open out of band, which a terminal cannot do without reaching outside the project, and a server told "yes" for a link that was only printed into scrollback is worse off than one that was told no. And headless runs advertise nothing, because there is nobody to ask — a server that sees no elicitation capability takes its own non-interactive path instead of waiting for an answer that is never coming.
The legacy HTTP+SSE transport (type:"sse") still works, but the spec has
deprecated it in favour of streamable HTTP and servers drop it on their own
schedule. /doctor warns when a configured server is still on it, because the
symptom otherwise is a connect error that says nothing about the one word that
fixes it.
The read-only sub-agents get read-only MCP tools. Fanning out across a wiki,
an issue tracker and a chat archive is what Explore and Plan are for, and it
is also the work whose bulk should never reach the main thread — a sub-agent
spends its own context and hands back a summary. A tool qualifies by declaring
the protocol's readOnlyHint; a tool that says nothing is treated as a write, so
delete_branch never arrives via this route.
readOnlyHint is a claim a server makes about itself, and nothing verifies it.
Per server, readOnly overrides that claim in either direction:
{ "mcpServers": {
// unset: trust each tool's readOnlyHint
"gitea": { "command": "gitea-mcp", "args": ["-t","stdio","-r"], "readOnly": true },
"sketchy": { "type": "http", "url": "https://third-party.example/mcp", "readOnly": false }
} }true for a server that annotates nothing — including one you launched in its
own read-only mode, where you know something the protocol wasn't told. false
to decline to take a server's word, without giving up the server: the main agent
keeps it and still asks before every call. This decides which tools a read-only
sub-agent is handed; it is not a claim that calling them is safe.
command, args, env values and url may reference Klaudia's environment
as ${VAR} or ${VAR:-default} — the syntax the reference MCP clients accept,
so a .mcp.json written for one of them works here unchanged:
{ "mcpServers": {
"loki": { "command": "python", "args": ["-m", "mspagent.mcp.loki"],
"env": { "MSP_LOKI_URL": "${MSP_LOKI_URL:-http://loki:3100}" } }
} }A reference to a variable that is unset and has no default is an error for that
server (named in the transcript and in /mcp; the other servers still start),
not an empty string — an empty value would vanish into the subprocess and
surface only as the server misbehaving. A bare $VAR is not expanded. The
server subprocess also inherits Klaudia's whole environment, so a credential the
server reads under its own name needs no env entry at all: export it in your
shell rather than writing it into the file. .mcp.json.example is a working
starting point; copy it and edit. .mcp.json itself is gitignored because a
credential in it would be a literal in a committed file — git add -f it if you
want a secret-free team config in the repo.
A stdio server's stderr is where it logs. In headless (-p) and embedding
runs each line is forwarded to Klaudia's stderr as mcp[<name>]: …;
interactive runs do not, because the TUI owns the terminal. Set
KLAUDIA_MCP_STDERR=<dir> to also append each server's stderr to
<dir>/<name>.log, in any mode. A server that fails to start has the tail of its
stderr appended to its connect error.
Worth pairing with the readOnly guidance above: -r and -S on the server
command narrow what exists at all, and readOnly decides who is handed it.
-S issue,pull_request,actions matters more than it looks, because every tool's
schema is sent on every request — loading 54 tools to use four is a permanent
context tax.
Klaudia talks to language servers you already have installed to give the agent real code intelligence:
Diagnostics— compiler/linter errors for a file (the edit → check → fix loop). A server that doesn't report within 10s (still starting or indexing) is returned as an error, not as a clean file.Definition/References/Implementation— jump to a symbol's definition, find its uses, or find the concrete implementations of an interface.Hover— the type, signature, and docs a server shows on hover.DocumentSymbols— an outline of the functions, types, and methods in a file.WorkspaceSymbol— find a symbol by name anywhere in the project, when the agent knows what it's called but not which file declares it. Given afile, that file's language server answers; without one, every language whose project file (go.mod,Cargo.toml,package.json, …) is at the workspace root is searched.Rename— a preview of the edits a symbol rename would make across the workspace (it returns the edits; it does not apply them).
Location, symbol, and hover results include the source line's text alongside
its file:line, so the agent sees the code without a follow-up read. A server
that lacks a capability is reported as "not supported" rather than failing.
Servers are detected, never downloaded — looked up on $PATH and in the
usual toolchain locations (so gopls in ~/go/bin, rust-analyzer in
~/.cargo/bin, global-npm bins, etc. are found even when not on PATH).
Recognised today: gopls (Go), rust-analyzer (Rust),
typescript-language-server (TS/JS), pyright-langserver (Python), clangd
(C/C++). They're launched lazily on first use and shut down at session end.
/doctor lists which servers it found. Turn one off with:
[lsp]
disabled = ["python"]Instructions are read into the system prompt from, farthest first so the closest have the last word:
~/.claude/CLAUDE.mdand~/.claude/rules/*.md;- every directory from
/down to the working directory: itsCLAUDE.md— orAGENTS.mdwhen it has none — then its.claude/rules/*.md.
So a workspace-level CLAUDE.md above several checkouts applies to all of
them. A line that is only @path is replaced by that file (relative to the
file it is in; ~/ is home; five levels deep); an @path inside a sentence
stays as written and the file is appended. @ inside code fences is left
alone, <!-- comments --> are removed, and each file is included once.
Besides the built-in general-purpose, Explore and Plan, sub-agents can be
defined as markdown files — the same format Claude Code and its plugins use —
in ~/.claude/agents/, ~/.klaudia/agents/, .claude/agents/ or
.klaudia/agents/ (later wins; a file can replace a built-in):
---
name: code-reviewer
description: Reviews a diff for bugs, error handling and style
tools: Read, Grep, Glob # optional; omitted means every tool
model: sonnet # optional; "inherit" or omitted uses the parent's
---
You are a code reviewer. …The model picks one by its description through the Agent tool; /agents lists
them. A sub-agent runs under the session's permissions, whatever its tools
say. On an OpenAI-compatible provider a Claude model is ignored.
Skills are read from four directories, in increasing precedence — so a project skill overrides an installed one of the same name:
~/.claude/skills/ ~/.klaudia/skills/ .claude/skills/ .klaudia/skills/
The two project directories are read at the project root (the git top-level),
so a launch from a subdirectory still gets the repository's skills; when the
launch directory is a subdirectory, its own .claude/skills/ and
.klaudia/skills/ are read after the root's and win a name collision.
.claude is included because that is where the ecosystem's skill installers
put things (anthropics/skills and friends), for the same reason Klaudia reads
~/.claude/CLAUDE.md. ~/.klaudia/skills/ follows KLAUDIA_CONFIG_DIR when it
is set; ~/.claude/skills/ does not. Either layout works in any of them:
skills/review.md # one file per skill
skills/review/SKILL.md # one directory per skill, for skills that ship
# templates, licences or scripts alongside
~/.claude/skills/synced/ — where Claude Code keeps the skills it syncs from
claude.ai — is skipped without a warning, and its skills are not loaded: many
of them drive claude.ai's own artifacts and connectors and would not work here.
Copy one you want into ~/.klaudia/skills/.
They become a Skill tool the model can invoke and /<name> commands in the
TUI. Body supports $ARGUMENTS. name defaults to the file's — or the
directory's — name.
---
name: review
description: Structured review of the current diff
---
Review the staged changes carefully. $ARGUMENTSThree skills ship inside the binary and are always available:
| Skill | What it does |
|---|---|
code-review |
Reviews the uncommitted diff (or a branch, range or PR number given as arguments) for real defects, confirming each against the code before reporting it. |
review-pr |
A pre-merge review that gives each aspect — correctness, tests, error handling, comments, design, simplicity — to its own read-only Explore sub-agent, then merges and checks the findings. Name aspects in the arguments to run only those. |
feature-dev |
A staged workflow for a non-trivial feature: explore with sub-agents, settle open questions with you, compare designs, implement, then verify and review. |
They follow the shape of Claude Code's code-review, pr-review-toolkit and
feature-dev plugins, rewritten for Klaudia's tools and sub-agent types (the
upstream prompts are not open-licensed, so none of their text is used). A skill
of the same name in any skills directory replaces the bundled one, which is how
you adapt one to a project.
Loaded skills are listed in the startup banner. A skill's name and description are in every request; its instructions load only when the skill is invoked (skill bodies are large, so this is deliberate) — a model saying "registered but not loaded" is reporting correct behaviour.
If a skill doesn't appear at all, run /doctor. It reports what loaded and
from which scope (bundled, user or project), and names the directories
when none of your own did. A skill directory without a SKILL.md warns at
startup rather than being skipped in silence.
A hook is a shell command run at a fixed point in a turn — the escape hatch for things Klaudia should not have an opinion about:
# .klaudia/config.toml — or ~/.klaudia/config.toml
[[hooks]]
event = "PostToolUse" # run the project's formatter after every write
matcher = "Edit|Write"
command = "gofmt -w $(jq -r '.tool_input.file_path') 2>/dev/null"
[[hooks]]
event = "PreToolUse" # refuse edits to generated files
matcher = "Edit|Write"
command = '''
case "$(jq -r .tool_input.file_path)" in
*.pb.go) echo "generated file — edit the source" >&2; exit 2 ;;
esac
'''
[[hooks]]
event = "UserPromptSubmit" # stdout is added to the conversation
command = "echo \"Branch: $(git branch --show-current)\""
timeout = "5s"Four events, deliberately: SessionStart, UserPromptSubmit, PreToolUse,
PostToolUse. The hook is handed a JSON object on stdin using Claude Code's
field names (tool_name, tool_input, tool_response, prompt, …) so
existing hook scripts work unedited. Exit 2 blocks and its stderr is the
reason the model is given; any other non-zero status is a malfunction, reported
to you and never to the model. stdout becomes context.
A hook is not a security boundary. PreToolUse runs after the host gate
and permission check have allowed a call, so a hook can narrow what Klaudia will
do and never widen it. And because a project's .klaudia/config.toml arrives
with a clone, hooks from a repository are confirmed once — you are shown
every command, the decision is remembered in ~/.klaudia/hooks.json, and
editing the set asks again. Your own ~/.klaudia/config.toml hooks run without
prompting. /doctor lists what is attached and warns about a set still waiting
on approval.
Full semantics: docs/hooks.md.
/theme recolors the whole UI — banner, prompts, menus, type-ahead, and
Markdown rendering, not just code blocks. Built in: dracula, gruvbox,
tokyo-night, nord, light, catppuccin. Persist a default in config
(project .klaudia overrides ~/.klaudia):
theme = "nord"Two complementary modes for working toward an objective:
- Standing goal —
/goal <text>pins an objective re-stated to the model at the start of every turn so it doesn't drift;/goal clearremoves it. It is kept beside the session's transcript, so resuming the session restores it (and the resume banner shows it). - Goal spec + Ralph loop — for bigger objectives:
/goal(no args) enters goal-setting: it loads an existing spec (./.klaudia/GOAL.md, or./PRD.mdif it has a spec's shape — at least one- [ ]checklist item and a## Verifysection) or, if none, helps you draft.klaudia/GOAL.md(objective, an acceptance-criteria checklist, and a verification command). APRD.mdwithout that shape is left alone, and Klaudia says why./goalagain finishes./goal run [N]then runs an autonomous loop against the spec: each iteration re-reads the spec, makes the next valuable change, verifies, and commits — progress accumulating in files and git, not the context window (the Ralph pattern). It runs on a dedicatedklaudia/goal-<slug>branch, stops when the model reports<goal-complete/>or afterNiterations (default 10, cap 50), and is interruptible any time withEscor/goal stop. The status bar showsgoal K/N. On an incomplete stop it runs a final wrap-up turn that records an end-of-run summary in the spec (what's done, what remains, the next step) so a re-run resumes cleanly. When it stops, it prints where the work landed and how to review/merge the branch — the loop never touches your starting branch.- Completion is gated on the spec, not the model's word. Before each run,
the spec's
## Progresstracker is scanned: if the body describes phases that the tracker doesn't list, the first iteration is a stub-fix turn that repairs the tracker. After every claimed<goal-complete/>the loop runs two checks — a mechanical count of remaining- [ ]items, and a one-shot verification turn that re-reads the spec from disk and cross-references it against git/build/tests — and only honours completion if both agree. - Your uncommitted work is not the loop's to discard. The loop runs
alongside whatever is already uncommitted — no clean tree needed — and
leaves it exactly as it was, uncommitted. For the whole run it is refused
any
git checkout --,git restore,git reset --hard,git clean -f,git stash,git rm -for forced switch that could reach a file that was dirty (or untracked) when it started, and anygit add -A/./-u,git commit -aorgit add <that file>that would sweep it into the loop's own commits — in every permission mode. It commits only what it changed. Opt-ins:/goal run [N] commitcommits the pre-existing changes to the goal branch first, as a commit of their own;/goal run [N] refusedoes not start if there are any. - The loop is
/goal runin the TUI and--loopon the command line; there is noklaudia goalsubcommand (klaudia goal runis refused rather than run as a prompt). - Headless/scriptable:
klaudia --loop --permission-mode autonomous [--max-iterations N] [--loop-dirty allow|commit|refuse]runs the same loop without the TUI (each iteration with a fresh context). It needs a spec in the cwd and a mode that does not ask (autonomous, or--dangerously-skip-permissions), and also stops if it stalls (no new commits for a few iterations). - Goals whose output is not a diff. For an analysis backlog whose
product is reports, packets or status files (often gitignored), the
branch and the commit per iteration are noise.
/goal run [N] no-branchstays on the current branch;no-commitleaves each iteration's work uncommitted for you to review;artifact(or a linemode: artifactin the spec) is both. In no-commit mode progress is the spec itself: the loop stops as stalled when its Progress section stops changing, not when HEAD does. Headless:--no-branch,--no-commit. (commitfor pre-existing changes needs a goal branch, so it cannot be combined withno-branch.)
Klaudia reads both, at three levels, in increasing precedence — generic first at each level, so an agent-specific file reads as the refinement:
~/.claude/CLAUDE.md ~/.klaudia/AGENTS.md <git root>/AGENTS.md <git root>/CLAUDE.md ./AGENTS.md ./CLAUDE.md
AGENTS.md is the cross-agent standard; CLAUDE.md is the
Claude-family one. Both are read because a repo may carry either or both, and
ignoring one silently drops instructions the user wrote for exactly this
purpose. ~/.claude is included for the same reason skills are read from
there: that is where the ecosystem puts things.
If you support both with a symlink (ln -s AGENTS.md CLAUDE.md) or a copy, the
contents are sent once, not twice — identity is established by resolved path
and by content hash. The prompt's section header names the files it actually
used, so asking Klaudia to "write that down" puts it in the right one.
- Auto-memory — the
Memorytool stores and recalls notes..klaudia/MEMORY.mdis the index (session bullets); longer notes live as.klaudia/memory/*.mddetail files. The index keeps a## Linked memorysection pointing at those files (name + one-line hook: the note's frontmatterdescription, else its first line after any frontmatter), kept in sync automatically. Only the index is recalled into the prompt — capped at 200 lines / 25 KB, with a line saying how much was left out — and the model opens a detail note on demand.Memorysearch spans both the index and the detail notes (a hit is tagged with its filename);removeforgets one session note (byquery) or a detail note (byname). - Project knowledge —
.klaudia/KNOWLEDGE.md(curated, durable lessons) is injected into the system prompt when present, framed as project notes to weigh against the code rather than as facts. Because every later session reads it, theMemorytool asks before writing it (addwithscope=project, andpromote) — in autonomous mode too. A headless run, with no one to ask, refuses such a write; plainMEMORY.mdnotes need no approval. - Both live in the project root's
.klaudia/(the git top-level; the launch directory outside a repository), so a launch from a subdirectory shares them. A subdirectory's own.klaudia/MEMORY.mdandKNOWLEDGE.md, from before memory was keyed by the root, are still recalled after the root's.
Every KLAUDIA_* and ANTHROPIC_* variable Klaudia reads. Where a
.klaudia/config.toml key covers the same setting (the [browser] keys), the
config file wins over the variable. Boolean variables take 1/true/yes/on
or 0/false/no/off; any other value is ignored, as is a number that does
not parse.
| Variable | Default | Purpose |
|---|---|---|
ANTHROPIC_API_KEY |
unset | Anthropic API key, sent as x-api-key. Wins over every other Anthropic credential. |
ANTHROPIC_AUTH_TOKEN |
unset | Bearer token for the Anthropic API, used when ANTHROPIC_API_KEY is unset; wins over the Claude Code Keychain session. |
KLAUDIA_CUSTOM_ENDPOINT |
Anthropic's production API | Base URL for the Anthropic provider (a proxy or gateway). The OpenAI-compatible provider uses baseURL in config.toml instead. |
KLAUDIA_MAX_RETRIES |
5 |
Retries for a failed model request (429, 5xx, dropped connection), with exponential backoff that honours Retry-After. A non-negative integer. |
KLAUDIA_STREAM_IDLE_TIMEOUT |
120 |
Seconds a streamed turn may go without an event before it counts as stalled (see Streaming & reliability). 0 disables the watchdog. |
KLAUDIA_DISABLE_PROMPT_CACHE |
unset (caching on) | Any non-empty value — even 0 — stops Klaudia marking prompt-cache breakpoints on Anthropic requests, so every turn re-sends and is billed for the whole prompt. |
KLAUDIA_CONFIG_DIR |
~/.klaudia |
Base directory for per-user state: sessions, tool-output spills, job logs, the Chrome profile and the user-level .mcp.json. config.toml and user skills are still read from ~/.klaudia. |
KLAUDIA_LOG |
unset (discarded) | File to append the standard log package's output to while the TUI runs (see Logs & diagnostics). |
KLAUDIA_BROWSER_LOG |
unset | File to append chromedp's unfiltered browser/protocol log to. |
KLAUDIA_MCP_STDERR |
unset | Directory to append each stdio MCP server's stderr to, as <name>.log, in any mode. |
KLAUDIA_WEB_SEARCH_ENGINE |
ddg |
BrowserSearch engine: ddg (DuckDuckGo) or google. Config: browser.searchEngine. |
KLAUDIA_CHROME_PATH |
auto-discovered | Chrome/Chromium executable to launch. Config: browser.chromePath. |
KLAUDIA_CHROME_REMOTE_URL |
unset (launch Chrome) | DevTools endpoint of a running Chrome to attach to instead of launching one. Config: browser.remoteUrl. |
KLAUDIA_CHROME_USER_DATA_DIR |
<config dir>/browser/chrome-profile |
Chrome profile directory (cookies, the solved-challenge state). Config: browser.userDataDir. |
KLAUDIA_BROWSER_HEADLESS |
true |
Run the launched Chrome headless. Config: browser.headless. |
KLAUDIA_BROWSER_HEADED_FALLBACK |
true |
On a search bot-challenge page, relaunch a headed Chrome so you can solve it once. Config: browser.headedFallback. |
Variables you name yourself — apiKeyEnv, extraHeadersEnv, ${VAR} in
.mcp.json — are not listed. cmd/klaudia/envdocs_test.go keeps this table
honest: it fails when non-test code names a KLAUDIA_* or ANTHROPIC_*
variable that has no row here, or a row names one no code reads.
| Package | Responsibility |
|---|---|
agent |
the agentic loop + sub-agent spawning |
api |
provider abstraction (Anthropic client + OpenAI-compatible shim) |
tools |
local tool implementations |
browser |
lazy headless-Chrome engine + web search |
lsp |
language-server client for code intelligence (Diagnostics/Definition/References/WorkspaceSymbol) |
permission |
the three permission modes (a leaf package) |
trust |
zones, command/tool classification, session-scoped grants |
session |
JSONL transcripts, resume, persisted summaries |
compaction |
micro + auto context compaction |
mcp |
Model Context Protocol client (2026-07-28) + form elicitation |
subagent |
built-in sub-agent types |
worktree |
per-sub-agent git checkouts: seed, adopt, conflict reporting |
skill |
user-defined skills |
hooks |
lifecycle hooks: events, project-hook trust, execution |
memory |
auto-memory store |
goal |
standing goals, goal specs, and the Ralph loop |
doctor |
/doctor environment diagnostics |
sandbox |
local / OS-confined / container Bash execution |
streamjson |
bidirectional stream-json frontend |
acp |
Agent Client Protocol v1 agent (editor-driven sessions) |
tui |
Bubble Tea terminal UI |
cli |
command entry, flags, wiring |
native |
pure-Go search / bash-parsing / PDF |
prompt, schema, config, version, tasks |
supporting packages |
- CHANGELOG.md — what changed, and why it was done that way
- docs/ux-spec.md — the terminal-UX specs, and where the implementation deliberately departs from them
- docs/trust.md — the host boundary, zones, and what is not protected
- docs/hooks.md — lifecycle hooks, and why project hooks are confirmed
- docs/jobs.md — the job model, logs, and its limits
- docs/working-tree.md — change ownership,
/commit,/undo, and the sub-agent checkouts - docs/acp.md — the ACP frontend: what is served, and what is declined
- docs/parity.md — JS→Go feature map and divergences
- docs/compaction.md — context-window management
- docs/memory-architecture.md — index→detail memory store
- docs/server-side-tools.md — Anthropic server-side tool schemas (reference)
Klaudia is a locally-buildable, extensible agentic coding tool for our team's workflow: one static Go binary with a self-contained tooling layer and room to grow tools, providers, and UI.
It began as a cleanroom extraction of Claude Code (@anthropic-ai/claude-code
v2.1.66) — prettified JavaScript split into src/sections/*.js — which served as
the golden reference for differential testing during a full port to Go. The port
is complete and is the product; the JavaScript reference (and the Go sidecar
tools that preceded the pure-Go native packages) is retired to the
js-reference branch — git checkout js-reference to consult it.
Builds are pure Go (CGO_ENABLED=0): the search / bash-parsing / PDF layers are
pure-Go too, so there are no wasm blobs, vendored binaries, or required system
tools.
- Bubble Tea TUI (not React + Ink).
- A multi-provider abstraction (the reference was Anthropic-only): Anthropic Messages API + an OpenAI-compatible shim.
- Local web search/browse via headless Chrome, for providers that have no server
tools of their own (the reference was Anthropic-only, and used its server-side
web_search/web_fetch— which Klaudia still prefers on Claude models, because their results come back cited). - Config, skills and sessions live under
~/.klaudia(or whereverKLAUDIA_CONFIG_DIRpoints), not~/.claude. - New capabilities with no reference analogue: language-server code intelligence
(Diagnostics/Definition/References/WorkspaceSymbol), OS/container Bash sandboxing, persisted
resume summaries, project
KNOWLEDGE.md, an index→detail memory store, standing goals (/goal), chrome-wide themes, managed background jobs with logs, working-tree change ownership (/changes,/undo), per-sub-agent git checkouts, an ACP frontend for editors, and an autonomy model that stops at the host boundary rather than at each action.
- Web: more robust search-result parsing; optional custom MCP auth headers.
- Provider breadth: image tool-results and richer translation across more OpenAI-compatible backends.
- Knowledge: let the agent curate
KNOWLEDGE.mdvia scoped Memory writes; evaluate embeddings for recall. - Extended tooling: project-specific analyzers and custom Go MCP servers.
- Reimplementing the Anthropic SDK or the Claude API.
- Cloud-provider SDK auth (Bedrock / Vertex / Foundry).
- A general-purpose fork — this targets our team's workflow.
{ "mcpServers": { "local": { "command": "my-server", "args": ["--stdio"] }, "remote": { "type": "http", "url": "https://mcp.example.com/v1", "headers": { "Authorization": "Bearer ${EXAMPLE_TOKEN}" }, "alwaysLoad": true } } }