Configuration¶
All configuration lives in kew.toml at your repo root. Every field has a sensible default.
Override precedence¶
kew resolves each configuration value in this order (highest wins):
- Environment variables — e.g.
KEW_MAX_BUDGEToverridesagent.max_budget_usd - Database overrides — set via the
kew serveweb UI Budget panel kew.toml— the declarative source of truth- Defaults — built-in sensible defaults
Database overrides are stored in the run database (not in kew.toml) and apply on the next dispatch or budget check. The web UI always shows which layer a value comes from. To revert an override, click Revert in the Budget panel — this deletes the DB row and the value falls back to kew.toml. The overridable fields are the budget caps (budget.per_issue_usd, budget.daily_usd, budget.weekly_usd, agent.max_budget_usd), the model/routing knobs (agent.model, routing.default, routing.simple, routing.medium, routing.complex), and the circuit-breaker sensitivity (breaker.cost_anomaly_factor). GET /api/config/overridable returns every one of them with its full resolution story (effective / file / default / override); the Budget panel in the web UI exposes the three budget fields today. Spend-authorizing overrides pass the [approval].approvers gate.
Dispatched agents use the committed
kew.toml. Each agent runs in a git worktree checked out fromorigin/<default-branch>, so it loads the committed config file — not your local working-tree edits. This applies to governance fields (budget caps, breaker settings, security patterns) as well as all other fields. Commit yourkew.tomlchanges before starting a dispatch run to ensure agents see them.kew dispatch(and other dispatch commands) warn at preflight when local governance fields differ from the committed file.
Project selection (--project / KEW_PROJECT)¶
By default kew finds kew.toml by walking up from your current directory to the git root. Every relative path in the config — logging.dir, and therefore dispatch.db — resolves against the directory that kew.toml lives in, not against your current directory. One project, one ledger, from any subdirectory.
That was not always true. Before #1156 a relative logging.dir resolved against the cwd, so each subdirectory you invoked kew from minted its own logs/dispatch/dispatch.db. The consequences were not cosmetic: kew audit verify reported a false COUNT-SHRINK when it read the ledger the anchors did not belong to, the daily budget cap could be reset by changing directory (spend is summed from the selected ledger), and the circuit breaker's cost-anomaly baseline was empty in a fresh directory. If a fork was left behind, kew now names it at preflight rather than silently ignoring it:
[dispatch] ⚠ Forked dispatch ledger(s) found under /home/me/work/svc: /home/me/work/svc/pkg/logs/dispatch/dispatch.db.
kew is reading and writing /home/me/work/svc/logs/dispatch/dispatch.db only — ...
Pick one ledger as canonical and archive the other; the keeper is normally the one your audit-anchors.jsonl verifies against and that holds the full history (kew audit verify --anchors audit-anchors.jsonl from the project root must report every anchor matched). kew backup the loser, then move it out of the project root — two live ledgers cannot be merged without breaking the hash chain, since each is a separate chain from its own genesis.
audit-anchors.jsonl follows the same rule: kew audit anchor writes the project-root file no matter where you run it. A relative path you pass explicitly to --anchors still means what you typed, relative to your cwd.
To pin the control plane explicitly, pass the global --project <dir> option (or set KEW_PROJECT=<dir>):
Both resolve kew.toml and the run database from that directory regardless of the current working directory — useful when there is no kew.toml to discover, or when you want to act on a project you are not standing in. --project simply sets KEW_PROJECT for the process, so the two are interchangeable; an explicit KEW_LOG_DIR still wins over the project-anchored log dir.
Every driver/dispatch command prints a one-line banner before it acts, so you can see which control plane you are touching:
[dispatch] project=/home/me/work/service-a tracker=github db=/home/me/work/service-a/logs/dispatch/dispatch.db
[project]
name = "my-project"
# repo = "owner/repo" # Auto-detected from gh CLI
[agent]
timeout_minutes = 120 # Kill agent after this long
max_budget_usd = 10.0 # Per-run budget cap
budget_stop_grace_seconds = 30 # Wrap-up window after a budget-cap SIGTERM so the
# agent can commit WIP before SIGKILL (graceful stop)
# max_steps is scaled by issue complexity (simple=80, medium=200, complex=400)
# Override per-loop with `kew dispatch --max-steps` or `kew loop resume --max-steps`
# runner = "" # Force a runner ("claude-code", "opencode", or "pi")
# model = "" # Override model (empty = default)
[agent.claude-code]
skip_permissions = true # --dangerously-skip-permissions
autocompact_pct = 70 # Context compaction threshold
[agent.opencode]
extra_args = []
# agent = "" # Optional: select an OpenCode agent preset
[agent.pi]
extra_args = [] # Pass-through CLI flags appended to every `pi` run
[agent.lemonade]
base_url = "http://localhost:13305/v1" # Any OpenAI-compatible local endpoint
# api_key = "" # Usually unnecessary for a local server
max_steps = 40 # Max tool calls per run (outer budget gate also applies)
extra_args = []
[branches]
prefix = "agent/issue-" # Branch: {prefix}{issue_num}
worktree_base = ".worktrees" # Worktree location
# base = "" # Override base branch (auto-detected if empty)
[commands]
ci = "make test" # CI gate (required to pass)
format = "" # Format command (run before CI); e.g. "black ." — empty = no format step
verify = "make test" # Final verification (defaults to ci value)
verify_loop = "" # Command the loop gate runs (kew executes; exit 0 = met)
verify_loop_mode = "command" # Gate mode: "command" or "ci_green" (#289)
ci_poll_timeout_secs = 1800 # Max seconds to wait for CI before needs_human (#289)
commit_prefix = "feat(#{issue}): "
co_author = "" # Co-authored-by trailer added to commits (empty = omit)
[prompt]
# template = "path/to/custom.md.j2" # Full Jinja2 override
# append = "Extra instructions..." # Appended to default prompt
include_safety_rules = true # No-op for the built-in template: the safety
# block (incl. the anti-merge/approve guard)
# always renders and cannot be dropped (#936)
include_execution_strategy = true # TDD workflow instructions
include_coderabbit = true # CodeRabbit pre-PR review
include_dispatch_report = true # Structured PR summary
include_repo_map = true # Structural code TOC in prompt
repo_map_tokens = 1024 # Max tokens for repo map
repo_map_mode = "hint" # "hint" (default): map ordered by the issue
# title/body, so repo_map_hash/prefix_key vary
# per issue. "stable": no hint, deterministic
# ordering, constant hash between merges. The
# trade-off is decided by a pre-registered
# experiment (docs/map-stability-experiment.md),
# not by flipping this default.
include_lessons = false # Inject committed .kew/memory/ lessons into
# the prompt, fenced as untrusted data below
# the Safety Rules. Off by default (no prompt
# change until you opt in).
lessons_corpus_tokens = 4096 # Hard cap on the injected lesson corpus;
# over-cap corpora truncate with a marker.
[security]
blocked_prefix_patterns = ["kubectl delete"]
blocked_exact_patterns = ["DROP DATABASE"]
[retry]
max_attempts = 3 # Total attempts (1 = no retry)
max_resume_attempts = 2 # Of those, how many try --resume first
backoff_base_seconds = 30 # Starting backoff delay
backoff_multiplier = 2.0 # Exponential multiplier (30s, 60s, 120s, ...)
retriable_exit_codes = [1]
[parallel]
max_workers = 1 # Concurrent dispatch workers (use --workers to override)
shutdown_grace_seconds = 30 # Wait time for workers on shutdown
[overlap]
strategy = "serialize" # Concurrent dispatch: "serialize", "warn", or "off"
[overlap.advanced]
llm_prediction = false # Use LLM to predict file-overlap risk (experimental)
llm_model = "claude-haiku-4-5" # Model for LLM-based overlap prediction
merge_context_ttl_minutes = 60 # How long merge context is retained (minutes)
[review]
auto_reply = true # Reply to PR comment threads after fixing
include_resolved = false # Include already-resolved comments
max_review_usd = 10.0 # Per-item cap for the adversarial reviewer dispatch
# (one fresh-context pass; separate from impl cap)
max_remediation_usd = 40.0 # Per-item cap for the governed remediation leg
# (fixing reviewer findings — has a very different
# cost profile from a single reviewer pass; raise
# for projects with large or complex fix cycles)
# --- Governed adversarial-review stage (opt-in) ---
adversarial = false # Enable the fresh-context adversarial review stage
blocking = true # true: a non-READY verdict HELDs the node for a human.
# false: verdict is attested and the node proceeds
# (the advisory adoption on-ramp)
max_rounds = 2 # Remediation cycles before a non-converging node HELDs
model = "" # Reviewer runner model ("" = runner default)
effort = "" # Reviewer reasoning effort: "", low, medium, high,
# xhigh, or max ("" = runner default)
runner = "" # Pin the reviewer's runner independently of node
# routing ("" = current behavior, claude-code).
# The reviewer runs contained on the image
# `[sandbox] images.<runner>` maps, with that
# runner's own flags and credentials. A runner with
# NO mapping fails closed — the reviewer never
# silently falls back to claude
min_severity_to_block = "high" # Policy severity floor: low, medium, high, or critical.
# Bound into every verdict's attested policy block
publish_status = true # Mirror the verdict onto the PR head SHA as a
# `kew-review` GitHub commit status (pending on
# dispatch → success on READY → failure otherwise).
# Require it via branch protection on `main` to block
# the GitHub merge button against a human merge race
# (best-effort: a publish failure never crashes the tick)
[resolve]
strategy = "merge" # Conflict resolution: "merge" or "rebase"
auto = false # Auto-resolve conflicts without prompting
[labels]
ready = "ready"
in_progress = "in-progress"
agent_working = "agent-working"
agent_failed = "agent-failed"
agent_pr = "agent-pr"
pr_pending_review = "pr-pending-review"
blocked = "blocked"
completed_no_changes = "agent-completed-no-changes"
agent_needs_help = "agent-needs-help"
incomplete_run = "incomplete-run" # Draft PR from a max-steps-incomplete run
budget_exceeded = "budget-exceeded"
large_pr = "large-pr"
ready_to_merge = "ready-to-merge"
overlap_hold = "overlap-hold"
priorities = ["p0-critical", "p1-high", "p2-medium", "p3-low"]
[triggers]
# NOTE: on_label, on_review, and on_ci_failure are config-only stubs for
# the upcoming event-driven trigger system. They are parsed but not yet
# wired to any runtime behavior.
on_label = true # Dispatch on label events (planned)
on_review = true # Dispatch on PR review comments (planned)
on_ci_failure = true # Auto-fix CI failures (planned)
ci_failure_max_retries = 2
[delegation]
# NOTE: kew delegate watch requires tracker.type = "linear". Supervised use is
# safe as of #966 (reservation lifecycle) + #971 (refusal-episode hardening);
# fully unattended use is pending the rest of the delegation-followups epic.
assignee = "" # The kew Linear member name (required at watch start)
label = "kew:ready" # Linear label that signals a delegated issue is ready
poll_interval_s = 20 # Throttled Linear API poll floor (seconds)
fast_interval_s = 2 # Local tracker-events read cadence (seconds)
refusal_cooldown_s = 300 # Skip re-claiming a budget-refused item this long (0 = off)
[budget]
per_issue_usd = 15.0 # Max spend per issue
daily_usd = 50.0 # Daily spend cap
weekly_usd = 200.0 # Weekly spend cap
[breaker]
# Automatic circuit breaker (#245). Kills a single run that burns more than
# cost_anomaly_factor x the median for its (model, complexity, run_kind) bucket
# — dispatch, review, loop, and planning runs are bucketed separately so a
# heavyweight review run isn't judged against cheap first-dispatch medians — and
# engages the fleet stop (`kew stop`) when live daily/weekly spend breaches the
# [budget] limits. The anomaly rule stays dormant until a bucket has min_samples
# completed runs. Degenerate near-zero-cost rows (< $0.01) are excluded from the
# bucket median. The trip point is the largest of median x cost_anomaly_factor,
# the bucket-relative floor (median x anomaly_floor_multiple), and the absolute
# backstop min_anomaly_threshold_usd — so a heavyweight-but-healthy run won't
# trip when the bucket median is small, without one dollar value having to
# serve buckets whose medians differ by orders of magnitude (#1116). An
# under-sampled bucket is judged against median x anomaly_floor_multiple, and
# arms only once it has at least three of its own samples whose median has
# reached the route reference (the same query with the run_kind predicate
# inverted — the median over the route's *other* kinds). That reference gates
# arming and never sets the threshold: it measures other work, so a bucket only
# ever seen cheaper than the rest of its route is no evidence about what a
# normal run of that kind costs, and arming off it would kill a first-ever
# review_remediation at 3x a cheap route's dispatch cost. Excluding the
# bucket's own kind is what makes that gate mean anything — read across all
# kinds it would pass for free on a route running a single kind. A thinner
# bucket, one with no samples at all, one whose route has no other kinds, and
# one with anomaly_floor_multiple = 0 all stay dormant rather than arming on
# the absolute floor alone. Each dispatch logs the armed threshold and which of
# the three terms won.
#
# compaction_allowance_factor discounts autocompaction overhead before the
# anomaly rule fires (#859): each autocompaction rewrites the whole context,
# adding a cache-creation spike, so the run cost is reduced by
# compaction_allowance_factor x the bucket median per compaction before it is
# compared to the threshold. This keeps a long run that legitimately compacts
# several times from tripping on the compaction cost alone. 0 disables it.
enabled = true
cost_anomaly_factor = 3.0
min_samples = 5
min_anomaly_threshold_usd = 0.0
anomaly_floor_multiple = 3.0
compaction_allowance_factor = 0.5
[approval]
# threshold_usd = 5.0 # Unset = feature off (default). Hold dispatches
# whose estimated cost is at or above this.
label = "needs-approval" # Hold label, created by `kew setup`
# approvers = [] # Optional allowlist of GitHub logins allowed to
# approve/deny. Empty = any write-access collaborator.
[routing]
default = "claude-code" # Default runner
# Per-complexity overrides:
# simple = { runner = "opencode", model = "gemini-2.5-flash" }
# medium = "claude-code"
# complex = { runner = "claude-code", model = "claude-opus-4-6" }
## `[routing.cache]` {#routing-cache}
Cache-economics-aware routing and TTL-aware dispatch ordering (Phases 2 & 3 of the cache-aware routing feature). Two independent opt-in switches — `cache_aware` (the routing governor) and `order_aware` (dispatch ordering) — both default **false**, so the whole feature ships as pure *advise-only* and each is opted into auto separately.
```toml
[routing]
cache_aware = false # routing governor: advise-only vs auto
[routing.cache]
governor_threshold_usd = 0.02 # anti-churn: min savings to override
order_aware = false # TTL-aware dispatch ordering: advise vs auto
max_reorder_lookahead = 10
| Key | Default | Description |
|---|---|---|
cache_aware |
false |
When true, the routing governor may substitute a warm, equal-or-stronger model for the proposed route if the expected savings exceed the threshold. Override-up-only: the governor never trades quality for cache savings. When false, the governor still computes and logs the decision (visible in the audit trail) but returns the proposed route unchanged — advise-only mode. |
order_aware |
false |
When true, the dispatch queue reorders ready issues within each priority tier to cluster those sharing a prefix_key adjacently, so they dispatch back-to-back inside the provider cache TTL (the first warms the prefix, the rest read cached). When false, the reordering decision is computed and attested but not applied. |
governor_threshold_usd |
0.02 |
Minimum expected savings for the governor to override the proposed route. Prevents churn for negligible gains. Must be >= 0. |
max_reorder_lookahead |
10 |
Maximum number of positions the ordering pass scans ahead within a priority tier to find same-prefix issues. Must be > 0. Prevents a distant same-prefix issue from starving age order. |
The governor (cache_aware): after the existing tier-based candidate selection, a two-stage pipeline runs: propose_candidate() (unchanged) → cache_governor(). The governor:
1. Checks whether the proposed provider caches and its read multiplier < 1.0 (the engage gate, borrowed from OpenRouter). If not, passthrough.
2. Builds a candidate set: {proposed} ∪ {warm models whose capability ≥ proposed}. Capability order comes from the tier ladder (simple < medium < complex).
3. Prices each candidate using estimate_route_cost — warm vs cold from the ledger-backed oracle.
4. Picks the minimum expected cost. If a warm, equal-or-stronger model beats the cold proposal by more than governor_threshold_usd, it recommends (or applies) the override.
The ordering pass (order_aware): after the existing priority + age sort, a within-tier reorder clusters issues with the same prefix_key adjacently. The cluster key is (runner, model) for the tier. The pass is bounded by max_reorder_lookahead and never crosses priority tiers. With max_workers > 1, simultaneously-fired same-prefix issues all start cold — v1 clusters them adjacently and lets natural worker cadence warm them; the "stagger across worker slots" refinement is deferred.
Known limitations:
- prefix_key is a hash of (repo, runner, model, provider, prompt-template version, repo-map hash). It does not capture the runner's internal system+tools prompt (e.g. Claude Code builds its own), but those are byte-constant for a given runner version; a runner version bump simply shows up as more cold runs until things re-warm.
- Caches never cross providers — any cross-provider switch is a guaranteed cold start. The governor accounts for this when comparing candidates.
Per-provider cache parameters. kew ships with built-in cache economics for three providers. Unknown providers (e.g. z.ai, GLM) default to caches=false (treated as always-cold — conservative). The defaults live in source (kew.pricing.PROVIDER_CACHE_DEFAULTS) and are documented below for reference; the config-seam for overrides ([routing.cache.providers.*]) is a planned extension — open an issue if your provider needs different values.
| Provider | Caches? | Read mult | Write mult | TTL | Min tokens | Cache style |
|---|---|---|---|---|---|---|
anthropic |
yes | 0.10× | 1.25× | 300s | 1,024 | explicit |
openai |
yes | 0.50× | 1.0× (implicit) | 600s | 1,024 | implicit |
google / Gemini |
yes | 0.25× | 1.0× (implicit) | 300s | 32,768 | implicit + explicit |
z.ai, GLM, unknown |
no | — | — | — | — | confirm before enabling |
- Explicit providers (Anthropic) charge a write premium (1.25×) on cache creation and a steep read discount (0.10×). Cache TTL resets on each hit (rolling).
- Implicit providers (OpenAI, Google) cache automatically with no write premium (1.0×). Google requires a larger minimum prefix (32K tokens) for eligibility.
- Multipliers are time-sensitive — kew's defaults match each provider's published pricing at the time of release. Verify against live docs if you rely on precise cost projections.
- Unknown providers default
caches=false— treated as always-cold, with no write premium applied to input costs. Setcaches=trueexplicitly once caching support is confirmed for your provider.
Audit trail. Every governor decision is attested: the routing_decision audit event records the proposed model, recommended model, candidate set, warm prefix set, estimated savings, the decision reason, and whether it was applied (cache_aware=true) or advise-only (cache_aware=false). Every ordering decision is attested as a cache_reorder event. Run kew audit tail | grep routing_decision to inspect recent governor decisions.
[pricing]
Custom model pricing overrides: [input_cost, output_cost] per million tokens¶
overrides = { "custom-model" = [3.0, 15.0] }¶
Prompt-cache tokens are priced automatically as multiples of the input rate¶
(cache write 1.25x, cache read 0.1x), so overrides only need input/output.¶
[billing] mode = "metered" # "metered" (API key, list price ≈ real spend) or "subscription" plan_label = "" # e.g. "Claude Max" — shown in the value banner (empty = omit) plan_monthly_usd = 0.0 # Monthly subscription cost for the value-leverage banner
[quality] max_diff_lines = 500 # Warn if diff exceeds this large_pr_threshold = 500 # Label PR as large above this
[alerting]
webhook_url = "" # Slack/Discord webhook for notifications¶
budget_warning_threshold_usd = 25.0 budget_limit_usd = 50.0 notify_on_failure = true notify_on_success = false notify_on_hold = true # Comment + webhook when a dispatch is held for approval
[ingest]
enabled = false # master switch for kew ingest serve
host = "127.0.0.1" # receiver bind host; loopback-only
port = 4318 # OTLP/HTTP default
cap_usd = 0.0 # 0 = no cap alert
combine_with_dispatch = false # cap against interactive only, or interactive + dispatched
[logging]
dir = "./logs/dispatch" # relative → resolved against this kew.toml's directory
level = "info" # debug, info, warning, error
retention_days = 0 # cutoff (days) for kew export --purge; 0 = keep forever
`retention_days` is **not** an enforcement setting — nothing is deleted automatically. It is only the cutoff used when an operator explicitly runs `kew export --purge`; rows older than the cutoff are removed at that point and never otherwise. See [Operations](operations.md) for backup/restore and the manual pruning flow.
## `[tracker]`
Which work tracker the project reads from. Today this selects the source for
the `kew epic` graph commands; dispatch still runs on GitHub issues (full
Linear execution is phased — see the Linear surface spec).
```toml
[tracker]
type = "github" # "github" (default) or "linear"
[tracker.linear]
team = "" # Linear team key (reserved for the execution phases)
in_review_state = "In Review" # your team's type-`started` review state name
With type = "linear", an epic ref names a Linear project and dependency
edges come from Linear's native blocks/blocked-by relations — no
kew:depends markers needed. Auth is environment-only: set LINEAR_API_KEY
(a personal API key; it is never written to config, run records, or logs).
[delegation] {#delegation}¶
NOTE:
kew delegate watchrequirestracker.type = "linear". Supervised use is safe as of #966 (the reservation lifecycle no longer deadlocks the fleet) and971 (refusal-episode hardening: comment once per episode, a reconcile retry¶
surface for a failed unclaim, and a fatal exit on an expired/revoked key). Fully unattended operation is pending the remainder of the
delegation-followupsepic. Seekew delegatefor details.
Assign-to-kew delegation settings. A human assigns a Linear issue to the kew member
(or adds the configured label) and kew delegate watch claims → dispatches it as a
one-node ephemeral epic under the [parallel] max_workers fleet cap.
[delegation]
assignee = "" # The kew Linear member name (required at watch start)
label = "kew:ready" # Linear label that signals a delegated issue is ready
poll_interval_s = 20 # Throttled Linear API poll floor (seconds)
fast_interval_s = 2 # Local tracker-events read cadence (seconds)
refusal_cooldown_s = 300 # Skip re-claiming a budget-refused item this long (0 = off)
assignee(required at watch start, default"") — the Linear display name of the kew seat. Resolved to a member ID at startup; the watcher refuses to start if blank.label(default"kew:ready") — a Linear label name. Only issues that carry this label (at re-verify time) are claimed; other assign-to-kew events are ignored.poll_interval_s(default20, must be>= 1) — the minimum seconds between Linear API polls. The watcher may poll less frequently if no events are pending; it never polls faster.fast_interval_s(default2, must be>= 1) — the seconds between localtracker_eventsreads (the in-process cursor store). Decoupled from the Linear poll to keep the local loop responsive without burning API quota.refusal_cooldown_s(default300) — after a budget/preflight refusal, the watcher skips re-claiming that item for this many seconds, damping the refuse→comment loop a surviving trigger label would otherwise drive every poll. kew also comments only once per refusal episode (#971), so a persistently over-budget item is not re-commented on every cooldown expiry.0disables the cooldown.
Governance attestation¶
Policy attestation (#250). On every dispatch, kew records a
policy_hashover a redacted snapshot of the governance config —[budget],[approval](threshold, label, approvers),[routing],[security]patterns, the per-run/limit caps, and the deployed.claude/hooks/security-guard.shdigest. Secrets (e.g.[alerting] webhook_url) are never hashed or retained. Resolve a hash withkew audit policy <hash>.Reported-model (#251). The runner-reported resolved model is recorded per run as
reported_modelalongside the configured[agent] model, and surfaced inkew reportas a "Reported model" breakdown. Runners that report nothing (opencode) produce a null value.
[audit] {#audit}¶
Tamper-evidence policy for kew audit verify --anchors (see
Audit log & tamper-evidence).
| Key | Default | Description |
|---|---|---|
staleness_window |
0 |
How many events the live chain may trail the latest anchor's count before verification fails closed. In honest operation the live DB is at or ahead of the newest anchor (anchors trail the head), so any positive lag beyond this window signals truncation or a stale/lagging anchor. 0 demands the live chain be at least as long as the latest anchor. Raise it to the number of events a legitimately-behind clone may trail. Env override: KEW_AUDIT_STALENESS_WINDOW. |
[approval]¶
Pre-dispatch approval gate. When threshold_usd is set, any dispatch whose
estimated cost meets or exceeds it requires explicit human sign-off.
Estimates are the median of the last 10 completed runs matching the issue's
complexity and model; with fewer than 3 matches kew assumes the worst case
(budget.per_issue_usd) and says so.
[approval]
threshold_usd = 5.0 # unset = feature off (default)
label = "needs-approval" # hold label, created by `kew setup`
approvers = ["alice", "bob"] # optional: only these logins may approve/deny
approvers(optional, default[]) — a GitHub-login allowlist enforced bykew approve/kew deny. Empty means any collaborator with repo write access may approve (the boundary the remote-approval workflow checks). Set it to narrow approval to specific people. Seekew approvefor the terminal-free remote-approval flow (#236).-
[alerting] notify_on_hold(defaulttrue) — when a dispatch is held, kew comments the/approve·/denyinstructions on the issue (and POSTs towebhook_urlif set). Setfalseto suppress. -
Interactive (
kew runon a TTY): kew shows the estimate and its basis and asks for confirmation. Declining returns the issue toready. - Non-interactive (
--loop, scheduler, TUI, no TTY): the issue is held with theneeds-approvallabel and the queue moves on. Release it withkew run --issue N --approve. --forcebypasses budget checks only — it never bypasses the approval gate. The two flags are independent and composable.- All gate decisions are recorded in the audit trail (
kew audit tail) asapproval_hold,approval_granted,approval_confirmed, orapproval_declinedevents with the estimate, basis, and threshold.
commands.verify — a command kew injects into the agent's prompt for the agent to run as a final check before committing; kew does not execute it itself (contrast commands.verify_loop below, which kew's loop gate runs directly). Defaults to the value of commands.ci if not set. Use this when your full CI suite is too slow or broad for the agent's inner loop — point commands.ci at your full suite and commands.verify at a faster subset:
[commands]
ci = "make test" # Full CI gate
verify = "make test-fast" # Quick verification the agent runs before committing
commands.verify_loop — the command kew's loop verification gate runs to
decide whether a kew dispatch <issue> --loop loop has genuinely met its goal.
Unlike commands.verify (which is prompt text the agent reads before
committing), verify_loop is executed by kew itself: exit 0 means the goal
is met and the loop may close as achieved; any non-zero exit keeps the loop
open or sends it for review. A loop never closes on the agent's word alone — the
gate's verdict is the sole authority (the anti-proxy invariant). For safety, if
the loop's diff edits any test file, any conftest.py, the verify command's
script, or .github/workflows/**, the gate returns a sabotage verdict and
refuses success (self-sabotage defense). The gate only returns this verdict;
kew's loop driver is what acts on it (closing the loop as achieved, or routing
a failed/sabotage verdict to needs_human). This command must be set by the
operator in kew.toml only — it is executed as a shell command, so it must
never come from an issue or agent. Empty (the default) falls back to
commands.ci.
[agent.opencode]¶
Settings for the OpenCode multi-provider runner. OpenCode covers Gemini, GPT/o-series, Ollama, OpenRouter, and 70+ other providers through a single integration.
Routing: Any model written as provider/model (e.g. google/gemini-2.5-pro, openai/gpt-5.1-codex, ollama/llama3) routes to OpenCode automatically. Bare prefixes gemini-, gpt-, o1-, o3-, o4-, and ollama/ also route to OpenCode.
Credentials: OpenCode manages its own provider credentials. Run opencode auth login <provider> once before dispatching jobs to a new provider. Kew does not store API keys for OpenCode-routed providers.
[agent.opencode]
extra_args = [] # Pass-through CLI flags appended to every `opencode run`
agent = "" # Optional: select an OpenCode agent preset via `--agent <name>`
Known limitations (v1):
- No native budget enforcement — kew's outer dispatch budget gates apply instead.
- No attached-server mode — every dispatch spawns a fresh opencode run process.
[agent.pi]¶
Settings for the Pi runner (@earendil-works/pi-coding-agent), a minimal, multi-provider terminal coding agent. Kew drives it via pi --mode json -p <prompt>, consuming pi's JSON event stream.
Routing: Pi is selected explicitly only — set runner = "pi" in [agent] or a [routing] entry. Pi shares the provider/model naming convention with OpenCode, so kew does not auto-route models to it (that would collide with OpenCode). Models are written provider/id, e.g. anthropic/claude-sonnet-4-6 or openai/gpt-5.1.
Credentials: Pi manages its own provider credentials — set the relevant env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …) or run pi then /login for OAuth once. Kew does not store API keys for pi-routed providers. A missing credential is reported as a permanent failure.
[agent.pi]
extra_args = [] # Pass-through CLI flags appended to every `pi` run (e.g. ["--thinking", "high"])
Known limitations (v1): - No native budget enforcement — kew's outer dispatch budget gates apply instead. - Pi reports per-message token usage and native cost; kew accumulates tokens and prices them via its own pricing tables (same as other runners).
[agent.lemonade]¶
Settings for kew's own tool-use loop over a local OpenAI-compatible endpoint. Supports
AMD Lemonade, LM Studio, llama.cpp's llama-server, vLLM, Ollama, and any other
server that speaks the OpenAI /v1/chat/completions API. Cost is $0 (local); nothing
leaves the machine. See the Lemonade runner guide for
prerequisites, model selection, and examples.
Routing: Set model = "lemonade/<model-id>" in [agent]; the lemonade/ prefix
auto-routes to this runner — no explicit runner field needed.
[agent.lemonade]
base_url = "http://localhost:13305/v1" # Any OpenAI-compatible local endpoint
# api_key = "" # Usually unnecessary for a local server; leave blank
max_steps = 40 # Max tool calls per run (outer dispatch budget gate also applies)
extra_args = [] # Pass-through CLI flags appended to every kew-lemonade run
Environment variable overrides (useful in CI):
| Variable | Overrides |
|---|---|
KEW_RUNNER |
agent.runner |
KEW_TIMEOUT (or DISPATCH_TIMEOUT) |
agent.timeout_minutes |
KEW_REPO (or DISPATCH_REPO) |
project.repo |
KEW_MAX_BUDGET (or DISPATCH_MAX_BUDGET) |
agent.max_budget_usd |
KEW_LOG_DIR (or DISPATCH_LOG_DIR) |
logging.dir |
KEW_MAX_WORKERS (or DISPATCH_MAX_WORKERS) |
parallel.max_workers |
WORKTREE_BASE |
branches.worktree_base |
KEW_PROJECT (or --project) |
config + DB directory — see Project selection |
[flight_recorder] {#flight_recorder}¶
Settings for kew serve — the approval inbox and web UI.
[flight_recorder]
host = "127.0.0.1" # Bind host (default: loopback)
port = 8765 # Bind port
expose = false # Required to bind a non-loopback host
extra_origins = [] # Extra browser Origins to trust for writes (reverse proxy)
host(default"127.0.0.1") — the addresskew servebinds to. Loopback (127.0.0.0/8,localhost,::1) is accepted without further configuration. Any non-loopback value requiresexpose = true(see below).port(default8765) — the portkew servelistens on. Must be in the range 1–65535.expose(defaultfalse) — opt-in to binding a non-loopbackhost. Whentrue,kew servemay bind an externally reachable address, and all reads (not just writes) require a valid session. Must be set explicitly to prevent accidental exposure.extra_origins(default[]) — additional browserOriginvalues to trust for writes. Needed only when frontingkew servewith a TLS reverse proxy: the browser sends its public origin (e.g.https://kew.example.com, no internal port), which never matcheshost:port, so writes would silently403. Each entry must be a bare origin — scheme + host, optional port, no path/query/fragment (e.g.["https://kew.example.com"]). Reads are unaffected. See Reverse proxy + TLS.
Migration note. Before this field existed, a non-loopback
[flight_recorder].hostwas accepted silently. If you have an existingkew.tomlwith a non-loopbackhostand noexposesetting, anykewcommand will now fail to load config with an error like:[flight_recorder].host='0.0.0.0' is non-loopback; set [flight_recorder].expose = true to bind it intentionally.Add
expose = trueto your[flight_recorder]section to restore the previous behavior. Be aware that this makes the inbox reachable beyond localhost — see Security: Approval inbox for the full threat model.
The --expose CLI flag overrides expose = false in config for a single kew serve invocation.
[ingest]¶
Cross-runner usage ingestion. Lets kew maintain one self-hosted record spanning dispatched runs and interactive usage from Claude Code. Off by default; opt in by setting enabled = true.
[ingest]
enabled = false # master switch for `kew ingest serve`
host = "127.0.0.1" # receiver bind host; loopback-only
port = 4318 # OTLP/HTTP default
cap_usd = 0.0 # 0 = no cap alert
combine_with_dispatch = false # cap against interactive only, or interactive + dispatched
enabled(defaultfalse) — must betrueforkew ingest serveto start.kew ingest fileworks regardless.host(default"127.0.0.1") — the bind host forkew ingest serve. Only loopback addresses are accepted: IPv4127.0.0.0/8, the literallocalhost, or IPv6::1. Any non-loopback value is rejected at startup. The receiver is local-only by construction; it is not designed to accept remote connections.port(default4318) — the OTLP/HTTP standard port. Must be in the range 1–65535.cap_usd(default0.0) — when greater than zero,kew reportemits one webhook notification if the relevant spend meets or exceeds this value. This is a reminder alert, not an enforced limit — kew cannot interrupt an interactive session. Effectively requires[alerting].webhook_urlto deliver.combine_with_dispatch(defaultfalse) — controls the spend basis forcap_usd.false= interactive-only spend;true= interactive + dispatched combined.
Cap alerts fire once per kew report invocation — they are stateless. Re-running kew report while spend remains above cap will re-alert.
See kew ingest for the full guide, including the Claude Code environment variables for kew ingest serve and how interactive usage appears in kew report.
[billing]¶
Records how API-equivalent costs map to your actual spend. kew always computes cost from published per-token API pricing. [billing] tells kew what that number means for your account so the value/leverage banner in kew report can frame it honestly (e.g. "included in subscription" vs. "real spend").
mode(default"metered") —"metered"means you pay per token via an API key; list price ≈ real spend."subscription"means token usage is included in a flat plan.plan_label(default"") — display name of the plan, shown alongside cost figures inkew report. Empty = omit.plan_monthly_usd(default0.0) — monthly cost of the subscription, used to compute value leverage in the report banner.
[overlap]¶
Controls how kew handles concurrent dispatch to overlapping files. When two queued issues touch the same paths, the configured strategy decides what happens.
[overlap]
strategy = "serialize" # default
[overlap.advanced]
llm_prediction = false
llm_model = "claude-haiku-4-5"
merge_context_ttl_minutes = 60
strategy(default"serialize") —"serialize"queues the second dispatch behind the first;"warn"proceeds but emits a warning;"off"disables overlap detection entirely.[overlap.advanced].llm_prediction(defaultfalse) — whentrue, uses an LLM to predict file-overlap risk before each dispatch (experimental).[overlap.advanced].llm_model(default"claude-haiku-4-5") — the model used for LLM-based overlap prediction.[overlap.advanced].merge_context_ttl_minutes(default60) — how long (in minutes) the merge context window is retained between overlapping dispatches.
The overlap_hold label (default "overlap-hold", configured under [labels]) is applied to an issue that is queued behind a running dispatch under the "serialize" strategy.
[resolve]¶
Merge conflict resolution options when a branch falls behind its base before dispatch.
strategy(default"merge") —"merge"or"rebase". Controls which git strategy is used when reconciling a stale branch with its base.auto(defaultfalse) — whentrue, kew attempts to resolve conflicts automatically without prompting. Whenfalse, a conflict surfaces as a failure and the issue may be routed toneeds_human.
[epic] {#epic}¶
Settings for the Epic DAG driver (kew epic advance / kew epic run / kew epic retry) — dispatching an epic:<name>-labeled dependency graph in order and mechanically merging PRs a human has already approved.
[epic]
# max_parallel = 2 # unset = config.parallel.max_workers
merge_strategy = "squash" # only "squash" is supported in Phase 2a
stall_days = 3
auto_ci_fix = false # opt-in: dispatch a bounded ci-fix run on red PR CI
# approvers = ["alice"] # unset/empty = falls back to [approval] approvers
# lock_path = "./logs/dispatch/epic-driver.lock" # unset = <logging.dir>/epic-driver.lock
max_parallel(default: unset, falls back to[parallel] max_workers) — the maximum number of epic nodes dispatched at once. Overridable per-invocation with--max-parallel.merge_strategy(default"squash") — only"squash"is supported in Phase 2a; any other value fails config validation. The merge always runs asgh pr merge <pr> --squash --match-head-commit <sha>, never--admin/--force.stall_days(default3) — the idle-time bound used by the per-node "stuck" clock for nodes without a more specific liveness signal (in-progress runs use run-store liveness; in-review PRs use the PR'spushedDate; a red required check goes stuck immediately). Must be>= 1.approvers(default[]) — allowlist of GitHub logins whose PR approval satisfies the merge gate. Empty falls back to[approval] approvers(and, like that field, empty there means any non-author write-collaborator qualifies).auto_ci_fix(defaultfalse) — whentrue, a tick whose node has a red (definitively failed) PR CI may dispatch a bounded ci-fix run (reusing the ci-fix machinery — budget-capped, at most one attempt per node per tick) instead of parking for a human. Infra-dead CI — a run whose jobs report zero executed steps (a billing failure or offline runner, which looks identical to a code failure) — is never fixable: the node is parked with aci-infra-deaddiagnostic and no agent run is dispatched. Off by default: recovery only engages when an operator opts in.lock_path(default: unset, falls back to<logging.dir>/epic-driver.lock) — base path for the driver's advisory lock. Locks are keyed per epic: the epic name is woven into the filename (e.g.epic-driver-<name>.lock), so two epics canadvance/runconcurrently while a second driver of the same epic exits cleanly rather than double-dispatching. The lock names its holder (pid, epic, held-since, heartbeat) and refreshes the heartbeat each tick. A stale lock — a dead-PID holder, or a heartbeat not refreshed within 300s (a frozen/hung driver) — is reclaimed automatically by the next driver (the steal is logged), so a killed driver's orphaned lock never leaves later drivers silently observing a dead epic.kew epic advance --takeoveris only needed for a legacy lock predating heartbeats whose staleness can't be proven; a live driver with a fresh heartbeat is never taken over.
See kew epic for the full command reference, the merge gate, and why there is no --auto-merge.
[report] {#report}¶
Branding for the kew report export evidence pack — the self-contained HTML artifact (and its JSON twin). Defaults to kew's own branding by reusing the shipped serve logo, so firm/client white-labeling is pure config.
[report]
brand_name = "kew" # header/footer product name
# brand_logo = "./brand/acme.svg" # unset = the shipped kew favicon
brand_accent = "#c81e5a" # #hex color or CSS color keyword
brand_name(default"kew") — the product name shown in the pack's header and page title. Must be non-empty.brand_logo(default: unset) — path to a logo (.svg,.png,.jpg,.gif,.webp). The file is read and inlined as a data URI so the HTML pack stays fully self-contained (no external fetches). Unset reuses the shipped kew favicon; a path that can't be read (or an unsupported type) simply renders the header without a logo.brand_accent(default"#c81e5a") — the accent color for the header rule and section headings, injected as a CSS custom property. Must be a#hexvalue or a bare CSS color keyword (validated to keep arbitrary text out of the pack's inline CSS). The default is kew's light-theme accent: the pack is a white-ground, printable document, and the dark-theme#ff2a6dmeasures 3.62:1 on white — below WCAG AA for body text. If you override it, check your value clears 4.5:1 on#f4f0fb.
Everything else in the pack is kew's light theme from frontend/src/app.css — the brand's system of record, shared with the TUI palette. The four IBM Plex faces (Sans and Mono, 400/600, latin subsets) are inlined as base64 woff2 (~110 KB), so the pack renders in kew's type with no network requests and stays a single file.
The attestation line always credits kew — Generated by kew vX.Y.Z, with the export timestamp and reporting period — even when the surrounding brand is white-labeled.
[sandbox] {#sandbox}¶
Sandboxed dispatch runs each agent inside an ephemeral container, isolating it from the host filesystem, credentials, and the audit database. By default (enabled = "auto") kew sandboxes automatically whenever a container runtime is present, and falls back to the host-subprocess path — with a one-line downgrade notice — when none is found.
[sandbox]
enabled = "auto" # "auto" (default) | true | false
runtime = "docker" # docker-compatible CLI (Docker Desktop, OrbStack, Podman)
image = "ghcr.io/thossullivan/kew-agent:latest" # agent container image (the claude-code image)
network = "allowlist" # container network mode ("allowlist" default | "open" opt-out)
on_missing_runtime = "host" # "host" or "fail"
[sandbox.images] # runner name -> container image
opencode = "ghcr.io/example/kew-agent-opencode:latest"
enabled(default"auto") — tri-state master switch:"auto"— at dispatch preflight, kew probes for the configured runtime (once per dispatch batch). Runtime found and an agent credential in the environment → dispatch is sandboxed and the banner says so (Sandbox: on (orbstack)). No runtime, or no credential → dispatch runs on the host guard-only path with a one-line downgrade notice pointing at how to get containment.true— hard-require containment: refuse to dispatch (fail closed) when no runtime is available or no agent credential is in the environment.false— never sandbox, silent. The host-subprocess path with its in-process guard is the only layer.
Containers cannot inherit CLI login
On a machine authenticated via claude CLI login, the credential lives in the OS keychain / ~/.claude — which kew deliberately never mounts into the container. A sandboxed agent therefore needs an environment credential, forwarded by name only (never by value in argv): either ANTHROPIC_API_KEY, or — to bill your existing Claude subscription instead of an API key — run claude setup-token once and export the result as CLAUDE_CODE_OAUTH_TOKEN. Without one, auto downgrades to guard-only with a notice, and enabled = true refuses to dispatch. kew sandbox smoke checks this as its final auth layer.
Existing enabled = true / enabled = false TOML keeps working unchanged.
runtime(default"docker") — the docker-compatible CLI binary. kew resolves it via the subprocess allowlist; OrbStack and Podman drop-ins work transparently.image(default"ghcr.io/thossullivan/kew-agent:latest") — the OCI image used for aclaude-codedispatch container. The container is ephemeral (--rm) and mounts only the standalone checkout — no host~/.claude,dispatch.db, or GitHub credentials. The stock image ships only theclaudeCLI, so this field is theclaude-codeimage; every other runner needs an entry in[sandbox.images].-
images(default{}) — a map of runner name (claude-code,opencode,pi,hermes,lemonade) to the container image that runs it. The image kew launches is chosen by the resolved runner for that dispatch, not by the environment: routing picks the runner, the runner picks the image. An entry here wins overimagefor the runner it names;claude-codefalls back toimagewhen unmapped, which is why existing configs keep working untouched. Keys must be runner names the registry knows — an unknown key is rejected at config load rather than sitting inert, since lookup is by exact name and a typo would never match anything.
A runner kew cannot contain is refused, not downgraded (fail closed)
When a container runtime is present, a dispatch is refused — under enabled = "auto" as well as true — if either half of containment is missing for its resolved runner:
- No image. No
[sandbox.images]entry, and notclaude-code. It is never launched in the claude image as a fallback: running an OpenRouter model string against theclaudeCLI just burns the dispatch on a 404. The message names the runner and the exact key to set:[sandbox] images.<runner> = "<image ref>". - No declared credential. kew forwards a runner's credential env vars into the container by name, and only
claude-code's are declared today. A mapped image alone would launch a container with no provider credential, authenticating as nothing — so mapping an image for another runner refuses until its declaration lands.
auto means contain when the host can contain. The runtime is present here, so the host can; the gap is on kew's side, and quietly dropping to the uncontained host path would be a posture change no operator asked for. The refusal is per-dispatch — sibling claude-code nodes in the same epic still dispatch contained — and is attested on the audit ledger as a sandbox_resolved event with posture = "refused" and a reason naming which half was missing (image_unmapped or credential_undeclared — see Audit log for every cause). To run that dispatch on the host deliberately, set enabled = false.
Under an epic driver the refused node is held, not failed: the driver's worker slot is released immediately (a terminal refused run row keyed to its reservation, so it never waits out the reconcile grace), and the node returns to the frontier when you map an image and run kew epic unhold <node>. kew epic status shows it as held on containment.
Prove a mapping before you route work at it: kew sandbox smoke --runner <runner> launches a real container from that runner's resolved image and names the first failing layer.
With no runtime present, enabled = "auto" still downgrades everything to guard-only as before; nothing here changes that.
An image must implement kew's container entrypoint contract: mounted checkout, prompt read from the mounted prompt file on stdin, extra args as "$@", one JSON event per line on stdout.
network(default"allowlist") — the container egress mode."allowlist"(default) — maps the container to a pre-configured Docker network namedkew-allowlist. Docker refuses to start the container if that network does not exist (fail closed — no silent fallback to open egress). Create the network and add host-level iptables rules to restrict egress toapi.anthropic.comandgithub.comonly:docker network create --driver bridge kew-allowlist."open"— explicit opt-out to full egress (omits--network; the Docker default bridge is used). Required until you configure thekew-allowlistnetwork. With open egress a prompt-injected agent could exfiltrate the inference credential via outbound HTTP — see SECURITY.md for the threat model.- Any other value — passed through as
--network <value>for custom Docker network names.
on_missing_runtime(default"host") — behaviour when the configured runtime binary is absent at dispatch time, forenabled = "auto"."host"falls back to the host guard-only path (the defaultautodowngrade);"fail"aborts the dispatch with an error even inauto.enabled = truealways fails closed regardless of this value. Any other value is rejected at config load.
The effective posture (sandboxed, guard-only, or disabled) is recorded on the audit ledger as a sandbox_resolved event per dispatch — along with the resolved runner, the image it ran in, and (on a refusal) the reason it was refused for — so kew audit shows whether a run was contained, and in what.