keke is configured through a TOML file at $KEKE_HOME/config.toml (defaults to ~/.keke/config.toml). All settings are optional — built-in providers work without any configuration.
Built-in providers can be used with the following credentials:
| Provider | Authentication | Notes |
|---|---|---|
| OpenAI / ChatGPT | keke login codex, or OPENAI_API_KEY |
Subscription OAuth or API key |
| Anthropic Claude | ANTHROPIC_API_KEY |
API key only; the default provider |
| xAI Grok | keke login grok, or XAI_API_KEY |
Subscription login or API key |
| Local (Ollama, vLLM, …) | none | Requests stay on the machine |
| OpenAI-compatible gateway | env_key |
Company proxies, NVIDIA NIM, and routers |
For example, a local Ollama endpoint needs only a provider declaration:
[providers.ollama]
base_url = "http://localhost:11434/v1"
default_model = "qwen3.8"Any OpenAI-compatible endpoint can be added by declaring a provider. This is how you use company proxies, NVIDIA NIM, Ollama, vLLM, or any custom gateway without rebuilding keke.
[providers.my-gateway]
base_url = "https://gateway.example.com/v1"
default_model = "gpt-4o"
env_key = "MY_GATEWAY_API_KEY"
wire = "chat_completions" # or "responses", "messages"A provider entry is an instance, not a vendor. kind names which built-in
implementation serves it, so one vendor can be registered more than once — at
two addresses, on two accounts — with neither being the other's special case.
xAI is the clearest case: a subscription login and an API key are spent at two different addresses, and each is refused at the other's.
# The subscription login, at the proxy where its included hours are spent.
[providers.grok]
kind = "grok"
# The pay-per-token API, on the key in $XAI_API_KEY.
[providers.xai]
kind = "grok"
base_url = "https://api.x.ai/v1"
wire = "chat_completions"
account = "apikey"
# The same vendor again, as a different person.
[providers.grok-work]
kind = "grok"
account = "work@corp.com"keke --provider xai then spends the key and keke --provider grok the login.
Known kinds are grok, codex, anthropic, ollama, and
openai-compatible. Leaving kind out means openai-compatible — except on a
route a built-in already answers to, where it means that built-in, so
[providers.grok] configures grok rather than quietly becoming a nameless
endpoint that happens to be called grok.
keke login files each login under the identity its token carries (an email,
usually), so one vendor's credential file holds as many accounts as you have.
account picks which one an instance authenticates as; leaving it out uses
whichever the file records as active.
The name apikey means the long-lived key from env_key rather than a login.
An instance naming any other account will not fall back to that key: an
instance configured as work@corp.com authenticating as whatever key happened
to be exported would spend the wrong quota under the wrong identity.
| Field | Required | Description |
|---|---|---|
kind |
No | Built-in implementation serving this instance: grok, codex, anthropic, ollama, openai-compatible (default) |
account |
No | Which stored account to authenticate as; apikey means the key from env_key |
base_url |
Unless kind is set |
Base URL of the API endpoint |
default_model |
No | Model used when none is specified |
env_key |
No | Environment variable holding the API key (e.g., NVIDIA_API_KEY) |
wire |
No | Wire format: chat_completions (default), responses, or messages |
ca_cert_path |
No | Path to PEM-encoded CA certificate for corporate TLS-intercepting gateways |
proxy |
No | Outbound proxy URL (e.g., http://proxy.internal:8080) |
proxy_username |
No | Basic-auth username for the proxy |
proxy_password_env_key |
No | Environment variable holding the proxy password |
headers |
No | Extra HTTP headers sent with every request |
web_search |
No | The vendor's own web search — see below. Off unless set |
service_tier |
No | Which queue turns start out routed at — fast or flex. Unset leaves the endpoint's own routing |
prompt_cache_ttl |
No | Messages-wire cache breakpoints: 5m (default), 1h, or off. Other wire formats keep their own cache routing |
A block named after a built-in route — grok, codex, anthropic, ollama —
configures that built-in rather than replacing it, so kind only needs stating
when you want a different one. Every other route defaults to
openai-compatible and needs a base_url.
Messages endpoints cache the final tool definition, the system prompt, and recent
completed message boundaries, with at most four breakpoints. Thinking blocks keep
their original signatures and receive no direct cache marker. Set
prompt_cache_ttl = "off" for gateways that do not support explicit caching.
[providers.anthropic]
prompt_cache_ttl = "5m" # "1h" extends retention; "off" omits markersCodex, Grok, and OpenRouter use the session identity for their respective routing fields instead. A routing key helps reuse but cannot guarantee a cache hit.
NVIDIA NIM's hosted endpoint (integrate.api.nvidia.com) automatically receives
the session ID in x-multi-turn-session-id for Chat Completions and Responses
requests, keeping tool steps and resumed turns on the same routing identity.
This is a backend affinity hint, not a guarantee that caching is enabled or hits.
See NVIDIA session stickiness.
For self-hosted gateways, configure the header through the provider's headers.
After an observed cache hit, a request with nonzero input and zero cached tokens
shows a yellow new session to save {n} tokens line directly above the TUI input.
The count is the most recent successful hit's cached-input-token count, representing
the cached prefix that is no longer being reused. The notice clears after the next
hit or /new; /clear retains it because the session continues. This applies to
all providers, including OpenRouter. A miss alone does not identify expiration or
eviction, and the count is not a guarantee of savings from starting a new session.
A codex instance can buy its turns at a different speed. fast asks for
priority routing — the same model at the same reasoning effort, answered
sooner, against a larger share of the account's allowance — and flex asks for
the deferred queue, which is cheaper and slower under load.
[providers.codex]
service_tier = "fast" # fast (priority routing), flex (deferred)In the TUI, /fast turns it on and off for the conversation in front of you,
and /fast flex or /fast off names a queue outright. The setting above is
only where a session starts: unlike /effort, a typed /fast is not written
back to config.toml, because a speed bought for one conversation that quietly
stayed on for every later one is what a person enables once and then pays for
forever. The bar says which queue is in force whenever one is named.
Left unset, keke names no queue at all, which is not the same as asking for the
standard one: the endpoint applies whatever default the model and the account
already have. An account that cannot buy the queue it named is refused by the
vendor, naming the tier, rather than being quietly downgraded here. Only
providers whose vendor sells more than one speed act on it — today that is
codex; the rest ignore it rather than failing.
An instance of kind = "codex", kind = "grok", or kind = "anthropic", or a
declared route whose base_url is on openrouter.ai, can offer the vendor's
web search. On every one but codex this becomes an ordinary web_search tool
in the model's tool list, dispatched and logged like any other tool call and
answered by a separate request to the vendor; a codex instance still hands the
search to the vendor to run inside the model call.
[providers.grok.web_search]
mode = "live" # that is the whole of a minimal grok setup
[providers.codex.web_search]
mode = "live" # disabled (default), cached, indexed, live
context_size = "medium" # low, medium, high
allowed_domains = ["docs.rs", "rust-lang.org"]
include_images = false
model = "grok-4-fast" # grok only: which model runs the search
[providers.codex.web_search.user_location]
country = "US"
city = "San Francisco"
timezone = "America/Los_Angeles"mode is what the search may reach, and the levels are not degrees of one
setting: cached answers only from what the vendor already holds, indexed
permits live fetches but confines them to pages it has indexed, and live
permits them anywhere. It is off by default because the search happens at the
vendor — no tool call reaches keke, so nothing you approve or guard locally
sees it. allowed_domains takes hostnames, not URLs, and a restriction written
against mode = "disabled" is rejected at startup rather than silently doing
nothing.
Over ACP, a session whose route can search offers a boolean web_search
config option (category _tools). It starts where this table puts it, off when
web_search is unset, and the client turns it on and off with
session/set_config_option; while it is off the web_search tool is not
listed and codex attaches no search. A route written down as
mode = "disabled" offers no option at all, so a client cannot turn on what
the deployment refused. With nothing configured, the client's switch uses
mode = "live" and the other settings' defaults.
Not every vendor can express every level. xAI's search always fetches live, so a
grok instance takes live or disabled; cached and indexed are refused at
startup, because approximating either would hand live web access to the
deployment that wrote down it may not have it. On that vendor context_size
chooses how many results are paid for, allowed_domains confines the search to
those sites (and, with it, to the web — xAI otherwise searches X as well),
user_location.country localizes results, and include_images has no
counterpart and is ignored.
A grok subscription login searches through the same setting. An xAI API key
at https://api.x.ai/v1 is declared as its own route:
[providers.xai]
kind = "grok"
base_url = "https://api.x.ai/v1"
[providers.xai.web_search]
mode = "live"On grok, allowed_domains is the deployment's policy and the model's own
per-call list can only narrow it — a model naming a site the config left out
does not get it. model names which model summarizes the results, since a
search is a self-contained call and need not be answered at conversation rates;
unset, the route's default_model runs it, and failing that whatever xAI lists
first.
On anthropic, the search runs Anthropic's server-side web_search tool.
Only live and disabled are accepted, for the same reason as on grok.
context_size sets how many searches one call may make (low 1, medium 3,
high 5), allowed_domains and user_location are passed through, and
include_images is ignored. model names which Claude model runs the search;
unset, the route's default_model does, and failing that the newest model
Anthropic lists.
[providers.anthropic.web_search]
mode = "live"
model = "claude-haiku-5-5" # optional: a cheaper model for the searchOn an OpenRouter route the search runs OpenRouter's openrouter:web_search
server tool, which works for any model: OpenRouter uses the model vendor's own
search where there is one and Exa otherwise. Again only live and disabled
are accepted, and the mapping is the same as on anthropic. OpenRouter lists
too many models for "the first one" to mean anything, so web_search.model or
the route's default_model must be set, and keke refuses to start without one.
[providers.openrouter]
base_url = "https://openrouter.ai/api/v1"
env_key = "OPENROUTER_API_KEY"
default_model = "anthropic/claude-sonnet-5.5"
[providers.openrouter.web_search]
mode = "live"On anthropic and OpenRouter, as on grok, the model's own per-call domain
list can only narrow allowed_domains. A call that asks only for domains the
config excludes fails rather than searching the whole web — on grok too.
Any other route (ollama, or a declared endpoint not on OpenRouter) has no
search to run, and turning one on there is refused at startup rather than
silently ignored.
Header values can be literal strings or environment variable references:
[providers.internal-gateway.headers]
X-Department-Token = "env:DEPT_TOKEN"
X-Company-User-Id = "milisp-labs"A value of the form env:VAR_NAME is resolved from the environment at startup rather than taken literally, so a secret header need not sit in the config file in the clear. authorization is reserved for the provider's own credential and cannot be set here.
# Local Ollama
[providers.ollama]
base_url = "http://localhost:11434/v1"
default_model = "gpt-oss:20b"
# NVIDIA NIM
[providers.nvidia]
base_url = "https://integrate.api.nvidia.com/v1"
env_key = "NVIDIA_API_KEY"
wire = "responses"
# Corporate gateway with TLS interception and proxy
[providers.internal-gateway]
base_url = "https://gateway.corp.internal/v1"
env_key = "INTERNAL_GATEWAY_API_KEY"
ca_cert_path = "/etc/ssl/certs/corp-root-ca.pem"
proxy = "http://proxy.corp.internal:8080"
proxy_username = "svc-keke"
proxy_password_env_key = "CORP_PROXY_PASSWORD"
[providers.internal-gateway.headers]
X-Department-Token = "env:DEPT_TOKEN"
X-Company-User-Id = "milisp-labs"Which account you want is usually a property of the repository you are in, not of the command you are typing. [[dir]] entries say that once, and keke picks the provider (and optionally the model) for every session started in a matching tree — the same idea as git's includeIf gitdir:.
[[dir]]
match = "~/work/**"
provider = "grok-work"
model = "grok-4.6" # optional
[[dir]]
match = "~/oss/**"
provider = "xai"| Field | Required | Description |
|---|---|---|
match |
Yes | Glob matched against the workspace root |
provider |
No | Provider route to use in matching directories |
model |
No | Model to use in matching directories |
Rules:
- The pattern is matched against the workspace root (the enclosing git repository, or the working directory if there is none), so an override follows the repo rather than which subdirectory your shell is in.
- A leading
~expands to your home directory. Paths are compared with/separators on every platform. - Globbing is deliberately small:
?is one character,*is any run of characters within one path segment, and a**segment matches any run of segments including none — so~/work/**matches~/workitself and everything under it. - When several entries match, the last one wins. Write the broad rule first and the narrow rule that refines it below.
- An entry that sets neither
providernormodelis rejected at startup, and so is one naming a provider that is not configured — it names the route and lists the ones that exist. --providerand--modelon the command line still win over a directory override.
These settings live at the top level of config.toml:
# Approval policy: "on_request" (default), "accept_edits", "auto", or "never".
# Old "on_failure" configurations still load, but new setups should use
# "on_request" with sandboxed Bash auto-approval below.
approval_policy = "on_request"
# Sandbox mode: "workspace_write" (default), "read_only", "danger_full_access".
# See "Sandbox" below for what each one enforces.
sandbox_mode = "workspace_write"
# Reasoning effort: "low", "medium", "high", "xhigh", "max" (default: "medium")
reasoning_effort = "medium"
# A persona: who the agent is and how it should behave. It leads the system
# prompt, after keke's own identity and before the project's AGENTS.md, so a
# repository still has the last word on how it wants to be worked on. Blank is
# the same as unset.
#
# Prefer `--instructions` or KEKE_INSTRUCTIONS when one installation drives
# several named agents at once: a persona is usually a property of the run
# rather than of the machine. Over ACP, a client may name one per session
# instead — see "Session identity over ACP" below.
instructions = "You are Ada. Review before you write, and say what you changed."
# Maximum output tokens per model reply (256-200000)
max_output_tokens = 8192
# Compaction configuration
[compaction]
trigger_percent = 80 # Percentage of context window at which compaction triggers
keep_recent_messages = 4 # Messages at the tail always kept verbatim
context_window = 128000 # Context window size compaction measures against
# Checkpoints: a snapshot of the working tree per turn, so a rewind (esc esc)
# can put the files back and not only the conversation.
#
# Snapshots live under $KEKE_HOME beside the session log, in a git repository of
# keke's own — never the project's, which keke does not commit to, stash, or
# stage. The project's .gitignore is honoured, so build output and node_modules
# stay out. A turn that only talks costs nothing: the snapshot is taken before
# the turn first runs a tool that can write.
#
# `keep_days` bounds the store: a snapshot older than this is dropped when a
# session opens the store, unless the session that took it is still working. A
# rewind that reaches back past the window finds the snapshot gone and says so.
#
# `max_tree_mb` is the working tree keke refuses to snapshot at all. A snapshot
# holds everything git would not ignore, so a project with a dataset, model
# weights or video in it that no .gitignore covers would be copied whole into
# $KEKE_HOME. Measured once, when the project's store is first created; over the
# limit, keke says so and runs with snapshots off for that project.
[checkpoints]
enabled = true
keep_days = 14
max_tree_mb = 2048
# Getting your attention when the terminal is not in front of you: a tool is
# waiting for approval, or a turn has finished (not one you stopped yourself).
# method "auto" (default) | "osc9" | "bel" | "off"
# auto sends an OSC 9 desktop notification in terminals known to show
# one (Ghostty, iTerm2, kitty, WezTerm, Warp) and the bell elsewhere.
# Inside tmux, OSC 9 needs `set -g allow-passthrough on`.
# condition "unfocused" (default) | "always"
# unfocused needs a terminal that reports focus changes; one that
# does not is treated as focused, so it never notifies.
[notifications]
method = "auto"
condition = "unfocused"
# What the model ends its commit messages with, in a git repository. Set it to
# "" to turn signing off — a project's .keke/config.toml can do that for every
# session in it. At most 512 bytes; several trailers go on separate lines.
[attribution]
commit = "Co-Authored-By: keke <noreply@milisp.dev>"
# Model catalog TTL in seconds (0 = ask every time, max 604800 = 7 days)
model_catalog_ttl_seconds = 21600 # 6 hours default
# Subagents: isolated child sessions `spawn_agent` starts
[subagents]
max_concurrent = 3 # How many run at once (1-16); further spawns queue
timeout_millis = 600000 # Wall-clock ceiling per subagent (60000-3600000)
collect_timeout_millis = 600000 # Default cancellable `collect_agent` wait window
# (10000-3600000, capped by timeout_millis).
# Returns as soon as any child finishes.
# Long waits avoid empty parent-model polling;
# user cancellation interrupts the wait.
# There is no depth setting: a subagent is never offered `spawn_agent` at all,
# so the tree is one level deep by construction rather than by configuration.
# Skills a plugin ships that this deployment does not want
# The TUI /skills panel also saves source and individual choices for the next
# launch. Configuration denials below cannot be overridden from the panel.
[skills]
disabled = ["acme:review", "deploy", "noisy-plugin:*"]
# Each entry is `plugin:name`, a bare `name` matching that skill in every
# plugin, or `plugin:*` for all of one plugin's. A disabled skill is not listed
# for the model, not offered as a slash command, and cannot be read by name.
# `disabled` accumulates across layers: a project can turn one off without
# restating the user's list, and no layer can re-enable what a broader one
# refused. An empty entry is an error rather than a request to disable
# everything.
# Plugin timeouts in milliseconds
[plugins]
hook_millis = 30000 # Hook timeout (100-3600000)
mcp_startup_millis = 15000 # MCP server startup timeout
mcp_call_millis = 120000 # Single MCP tools/call timeout
# How keke identifies itself when signing in to a remote MCP server
[mcp]
client_metadata_url = "https://www.milisp.dev/keke-agent/oauth/client-metadata.json"
# An OAuth Client ID Metadata Document (CIMD): the URL is keke's client_id,
# and an authorization server that advertises
# `client_id_metadata_document_supported` fetches it instead of asking keke to
# register. The default above is the document the keke project hosts; a build
# shipped under another name serves its own — `keke mcp client-metadata --url`
# prints the JSON to serve, over HTTPS, as application/json, at a URL that
# never redirects. `""` turns it off, leaving dynamic registration.
# The client_id is chosen in the order the MCP spec recommends: the server's
# own `oauth.client_id`, then this document, then dynamic registration.
#
# A server that later refuses a call with `403 insufficient_scope` gets no
# browser opened mid-turn. The call fails naming the missing scope, `/mcp` and
# `keke mcp get` mark the server as needing more access, and the next
# `keke mcp login` asks for that scope on top of the earlier ones.Every shell command the model runs — bash, in the foreground or the
background — runs under a sandbox the operating system enforces: Seatbelt
(/usr/bin/sandbox-exec) on macOS, Landlock plus seccomp on Linux. The
defaults follow codex's.
| Mode | Reads | Writes | Network |
|---|---|---|---|
workspace_write (default) |
anywhere | the workspace, the temporary directory, /tmp, and writable_roots |
off unless network_access = true; per-command domain and port approvals need macOS, or Linux with the namespace backend (see below) |
read_only |
anywhere | nowhere | off |
danger_full_access |
anywhere | anywhere | on |
sandbox_mode = "workspace_write"
[sandbox_workspace_write]
auto_approve_bash = true # default; sandboxed Bash skips ordinary approval
network_access = false # true grants unrestricted networking
allow_local_binding = true # macOS: localhost TCP listeners, no outbound grant
redirect_package_caches = true # cache-only paths in temp; toolchains/credentials unchanged
proxy_allowed_ports = [80, 443] # user may add 22/8443; projects may only remove ports
allowed_domains = ["github.com", "api.github.com", "registry.npmjs.org", "crates.io"]
denied_domains = [] # deny wins over allow, including wildcard matches
deny_read = ["/Users/me/private-credentials"] # absolute paths
writable_roots = ["/Users/me/.cache/sccache"] # absolute paths, writable tooOn macOS, workspace-write Bash uses a command-scoped CONNECT proxy automatically;
no hand-written allow list is required. Bash accepts a per-command allowed_domains
argument naming exact HTTPS hosts, for example api.github.com. There is no gh
special case. In Auto mode the guardian reviews the command and declared domains
together; grants last for that execution only. An undeclared destination is denied
without another guardian review. The result names the domain and instructs the
model to add it to allowed_domains and rerun inside the sandbox.
In human approval modes, a new domain prompts during execution and an ordinary
allow lasts for the session. “Allow this domain permanently” records it under
$KEKE_HOME/network-permissions.json for future human-mode sessions. Auto does not
reuse human session or persistent grants. Missing reviewers and refusals fail
closed. Never automatically approves declared and runtime domains without prompting,
but explicit denials and public-address checks still apply. Static allowed_domains
configuration remains optional for trusted endpoints. Explicit denials always
win, including over approved command endpoints.
Seatbelt permits only its 127.0.0.1 port, not arbitrary outbound connections.
Separately, allow_local_binding = true (default) permits localhost TCP servers
in macOS workspace-write mode. Use 127.0.0.1 or ::1 as the dev-server host;
wildcard/LAN binds such as 0.0.0.0 are not granted. A host browser can access
the server, including WebSocket traffic. Binding permission does not grant a
sandboxed curl, test runner, or browser client access to arbitrary localhost
services. Bash can declare allowed_local_ports: [3000] to approve localhost TCP
client access together with the command, for example curl http://127.0.0.1:3000.
This supports direct HTTP, HTTPS and WebSocket traffic on the approved ports,
including clients that do not use the domain proxy. Each call requires approval
(including Auto guardian review, except Never automatically authorizes); no port consent is remembered for the session
or permanently. Grants apply only to the command and its descendants, including
background commands until they exit. A grant can reach any host service owning
that port, not just a workspace dev server, so do not approve sensitive local
services casually. Port 0 and read-only are rejected. On Linux the grant needs the
namespace backend described below; without it Linux rejects the capability rather
than ignoring it. Projects may disable
binding but cannot restore it when the user disabled it. Read-only remains closed.
The child receives upper- and lowercase HTTP/HTTPS/ALL proxy variables and empty
NO_PROXY variables, except command-approved local access sets loopback exclusions;
Seatbelt still denies every other local port. Clients that ignore these settings remain blocked rather
than running unconfined. CONNECT and plain HTTP GET/HEAD downloads use only
proxy_allowed_ports (default [80, 443]). This is a user-authorized transport
ceiling separate from domain consent: granting a domain permits the configured
ports, not just HTTPS. Explicit [] denies all proxy transports; zero/duplicate
ports are invalid. Projects may only remove inherited ports, never add them.
HTTP bodies, pipelining, upgrades, SOCKS, IP literals and private DNS destinations
are refused. Use separately approved
local TCP ports for loopback services. HTTP downloads are not encrypted.
For macOS Git SSH, the default transport is routed through /usr/bin/ssh with an
nc -X connect ProxyCommand to the same domain gate. Add port 22 in the user config
for ordinary SSH, or use an SSH endpoint on an already permitted port. Domain
approval still applies and direct SSH remains blocked. Existing GIT_SSH or
GIT_SSH_COMMAND overrides are preserved, but cannot bypass Seatbelt. Standalone
SSH is not rewritten; configure its ProxyCommand explicitly. Host-key checks and
agent consent remain intact, and agent sockets still require allowed_unix_sockets.
CONNECT is an opaque TCP tunnel: allowing port 22/8443 does not verify the remote
protocol, and the proxy is not a TLS inspection boundary.
Domain names match case-insensitively; *.example.com matches subdomains, not
the apex. Redirects, download CDNs, enterprise hosts, and wrapped gh invocations
request additional domains during execution in human modes; Auto must declare
them and rerun instead of requiring an escape.
An allowed destination can still receive data from the workspace: the proxy
checks destinations, not tunnel contents, and does not inspect TLS.
Host services and cache writes. Bash accepts allowed_unix_sockets, exact
absolute socket paths such as the resolved Docker socket or $SSH_AUTH_SOCK.
macOS workspace-write grants outbound connection access only to reviewed paths;
other sockets remain blocked. Resolve shell variables before declaring paths.
The consent is per command, never stored. Docker access can control containers
and host mounts; SSH agent access can request signatures, so neither is a harmless
cache permission. Read-only rejects scoped socket grants. On Linux they need the
namespace backend and a kernel whose Landlock supports ABI 9 (ResolveUnix); on an
older kernel the call fails before the command starts rather than allowing every
socket, and abstract Unix sockets are scoped away from the command.
redirect_package_caches = true redirects only npm, Bun, pip, uv and XDG cache
environment variables to a stable per-workspace temporary location. The tools
create their caches there; keke does not copy or change the user's existing caches.
Cargo/Rust toolchain roots, credentials, HOME, PATH and install destinations stay
unchanged. Temp cleanup may remove the cache. Shell-level overrides remain subject
to filesystem permissions. The user may disable the redirects; projects may only
disable, never restore an inherited disabled setting.
Bash also accepts additional_write_roots, exact absolute existing directories,
reviewed with the command. This allows Cargo's existing registry/cache directories
or a chosen install destination without escaping the filesystem sandbox. Keke
canonicalizes approved roots, refuses missing directories rather than creating them
on the host, and keeps grants command-scoped; existing metadata protections apply
on macOS (Linux Landlock cannot carve out metadata). Grant narrow cache directories,
not HOME, the whole filesystem or directories holding toolchains/credentials.
Read-only rejects extra roots. Never authorizes them without review; Auto reviews
them with the command, and human modes ask afresh, without permanent or session grants.
Do not combine domain lists with network_access = true; configuration rejects
that bypass. Nonempty allow lists replace grants from earlier layers; denials
accumulate. Project configuration may add denials but cannot grant domains.
deny_read blocks file reads through Seatbelt, including configured paths and
their resolved targets; it does not remove inherited environment secrets or
implement credential masking. No credential paths are denied by default.
Linux supports an opt-in namespace filesystem backend configured by the user
with linux_namespace_launcher = "/usr/bin/bwrap" in
[sandbox_workspace_write]. Install non-setuid bubblewrap first. The launcher
and its ancestors must be root-owned and not group/world writable; repository
configuration cannot select it. Namespace creation is actually probed and
failure is fatal. This backend masks deny_read paths and overlays protected
metadata read-only, retaining Landlock/seccomp. It currently masks /proc
entirely, so workflows requiring procfs are not supported by this first stage.
With this backend the command runs in a private network namespace whose only
loopback is its own. A broker outside the namespace relays what the command may
reach: the domain proxy, and the ports approved through allowed_local_ports,
which are forwarded to the host's loopback. A command that was not granted a
destination cannot connect to it, and a host service is reachable only on a port
approved with that command.
Because that loopback is private, a dev server bound to 127.0.0.1 inside the
sandbox is not visible to a host browser. Bash declares exposed_local_ports: [3000] to publish named ports on the host's loopback for that command only. It
requires workspace-write with allow_local_binding enabled, rejects port 0,
and is approved with the command like allowed_local_ports (never remembered).
It exposes the server to host processes; it gives the command no access to host
services. Without the namespace backend Linux rejects it.
Without that backend, Linux refuses domain networking or read denials rather than claiming restrictions its launcher cannot enforce.
Stopping a command stops its processes. Every command leads its own process
group, and keke signals the group rather than only the shell. A timeout, a
cancelled turn, kill_task, clearing the task list and keke exiting all
reach a dev server the shell started. Foreground commands are sent SIGKILL;
background tasks get SIGTERM first and SIGKILL after background.kill_grace_millis,
so a server can remove its socket. A process that detaches into a new session or
group has left the group and is out of reach of this; on the Linux namespace
backend the namespace ends with the launcher, which takes such a process with it.
A foreground command returns when its shell exits, and anything it left running
in the background is stopped at that point, so a stray & neither holds the call
open until its timeout nor outlives it. A server meant to outlive the call needs
background: true. Plugin hooks and MCP servers get the same treatment: a hook
that times out, or an MCP server whose connection ends, takes down the launcher's
descendants (npx, uvx) with it.
Proxy resource budgets are configurable in the same section:
proxy_max_connections = 32, proxy_header_timeout_ms = 10000,
proxy_connect_timeout_ms = 10000, proxy_idle_timeout_ms = 60000,
and proxy_poll_interval_ms = 10. Values must be positive and are bounded;
projects cannot change them. Active workers are stopped and joined when the
sandbox is dropped; system DNS resolution may delay shutdown because the OS
resolver is not cancellable.
With approval_policy = "on_request" and workspace_write, enforced sandboxed Bash runs without
an ordinary approval prompt when auto_approve_bash = true, except when the call
predeclares network access: that access is reviewed together with the command.
Set it to false
to put Bash through ordinary approval before it runs. Edit tools still follow the
approval policy. This setting does not approve sandbox escapes: every
bash_unsandboxed call requires review unless the user selected Never. A repository may turn auto-approval
off, but may not turn it back on after the user turned it off.
approval_policy = "auto" sends ordinary approval requests to the model-backed
guardian reviewer. Its model defaults to the session model and can be selected
with [guardian] provider and model. A failed or unclear review denies the
call. Sandbox escapes also go to the guardian, on every call. never automatically
authorizes execution permissions, including escapes and scoped domain/port/socket/write
grants, without review; it is not Auto mode. It does not remove explicit guard
denials, sandbox restrictions, tools requiring a person's answer, or repository
plugin-content execution consent. Unconfined execution still requires using the
escape tool; Never does not silently run a failed sandboxed command unrestricted.
In Auto mode, write_file, edit, and apply_patch are decided by the paths
they write rather than by the reviewer: a call whose every path — for a patch,
every added, deleted, updated, and moved-to file — resolves inside the
workspace, after following symlinks, and outside .git, .agents, and
.keke, runs without review. Any other edit goes to the reviewer as before.
Other policies are unaffected; on_request still asks about every edit.
approval_policy = "accept_edits" accepts the built-in file edit tools
without an ordinary prompt. Commands and sandbox escapes keep their normal
approval rules.
[guardian]
provider = "codex"
model = "your-reviewer-model"The optional guardian model selection should be a model available through the
named provider. [guardian] enabled = true also lets it answer ordinary
on_request approvals; Auto mode uses it regardless of that setting. A
repository's config cannot choose or enable the guardian reviewer.
The old on_failure policy is accepted for existing configurations and
sessions, but is no longer offered in the interactive policy cycle. It skips
ordinary approval checks even for operations outside sandboxed Bash, so
on_request plus auto_approve_bash is the clearer choice.
Repository config cannot change on_request to accept_edits, auto,
on_failure, or never;
that choice belongs in the user's config.
require_approval names tools whose every call needs a person's answer, under
every approval policy:
require_approval = ["*stripe*", "acp:gmail:send_*", "*purchase*"]- Patterns. Matched against the full tool id, case-insensitively;
*stands for any run of characters,:included. MCP tools are<plugin>:<server>:<tool>, and servers sent by an ACP client areacp:<server>:<tool>. - Every policy.
neverstill asks.autodoes not let the guardian answer: an automatic reviewer is not a person. Where nobody can be asked (a non-interactive run, a client that refuses), the call is denied with a reason. - Once. A standing "allow always" never answers for a matching call, and
surfaces do not offer one: the terminal hides
a, and ACP requests omit the allow-always option. - Layers. Rules accumulate. A project may add rules but cannot remove the
person's.
--require-approval PATTERN(repeatable) andKEKE_REQUIRE_APPROVAL(comma-separated) add to the configured rules, for a program that launches keke for a particular agent. An empty entry in a file is a configuration error. - Over ACP. A request only a person may answer carries
_meta["keke.dev/person-required"] = true, andinitializereports_meta["keke.dev/require-approval"] = true. Both are keke extensions, not standard ACP fields. A client that answers on someone's behalf (an unattended run, say) must pass such a request to a person or refuse it. Every permission request also names its tool in_meta["keke.dev/tool"](bash,acp:shop:create_payment): ACP'skindisotherfor every MCP tool, too coarse for a client to remember an answer by.
keke agent stdio can serve several agents, one per session. A client names
who a session is in _meta["keke.dev/session"] on session/new,
session/load or session/resume:
{
"keke.dev/session": {
"instructions": "You are Daily Brief. ...",
"memoryDir": "/Users/me/.codexia/bots/brief/memory",
"requireApproval": ["*stripe*", "*:send_*"]
}
}Each key does for one session what KEKE_INSTRUCTIONS, KEKE_MEMORY_DIR and
KEKE_REQUIRE_APPROVAL do for the whole process, and is laid over them.
memoryDir must be absolute. requireApproval adds to the configured rules
and never removes one. A key of the wrong shape fails the open rather than
starting the session as someone else. The identity is restated on every open:
a resumed session does not recall it from its log.
Rules match names, not effects. They cannot recognize a payment made through a generic tool such as a shell command or a browser click; pair them with the sandbox, network rules and the provider's own account scopes.
Workspace metadata stays read-only. Inside a writable root, .agents and
.keke cannot be written by a sandboxed command, and neither can the parts of
the workspace's .git that Git executes or obeys: hooks, config,
config.worktree, info, and modules. A hook written there would run
outside the sandbox the next time you commit. The rest of .git is writable,
so git add and git commit are ordinary sandboxed Bash commands and need no
escape. .keke in the workspace and the protected Git paths are protected even
before they exist. This is enforced on macOS; Landlock cannot carve an
exception out of a grant, so on Linux those directories are as writable as the
rest of the workspace.
Stepping outside. When a command fails because of the sandbox — it needs
the network or writes outside the workspace — the model can
rerun it with bash_unsandboxed, giving a reason. In Auto mode the guardian
reviews every escape; human policies ask you every time, and Never skips execution
review. background: true explicitly requests a tracked unconfined task, with that
lifetime included in review; it ends on exit or kill_task. A standing "allow
always" never skips this review. If the required reviewer is unavailable,
the call is refused; keke exec can review escapes in Auto mode.
read_only writes nowhere. write_file, edit, and apply_patch write
from keke's own process, where no sandbox reaches, so under read_only they
are not offered at all — nor is bash_unsandboxed — and a guard denies those
tool names even if a plugin supplies one. Asking a person is not a boundary a
client that approves on your behalf can be held to. If you want writes you
review, use workspace_write with approval_policy = "on_request".
Choosing the mode per run. --sandbox <mode> (or KEKE_SANDBOX) overrides
sandbox_mode for one run. It is your own command line, so it may name any
mode, including danger_full_access; read-only spelled with hyphens works
too.
Choosing the mode over ACP. An ACP client sees a sandbox_mode config
option ("Sandbox": read_only, workspace_write, and danger_full_access as the
configured mode allows) and may switch it with session/set_config_option,
taking effect on the next command or tool listing. The configured mode is a
ceiling: a client may narrow it, never widen it, so a workspace_write
deployment offers read_only and workspace_write, and a read_only
deployment offers only itself. A mode this machine cannot enforce is refused
and the previous one stays. The choice is not written to the session log: a
resumed session starts at the configured mode again.
Where there is no sandbox. Windows has none — as in codex, where it is
off unless enabled — because what a non-administrator can build there cannot
block the network and leaves any directory others may write writable. So on
Windows, under any mode but danger_full_access, every bash command asks you
first, and keke exec refuses to run commands. Set sandbox_mode = "danger_full_access" to run them without asking. On Linux, a kernel without
Landlock (before 5.13, or with it disabled) refuses to start a session and
says why, rather than running commands unconfined.
A repository cannot loosen the sandbox. .keke/config.toml arrives with
git clone, so a project layer may tighten sandbox_mode or turn
network_access off, but asking for a looser mode, network access, or extra
writable_roots is an error naming the file. Put those in
$KEKE_HOME/config.toml to grant them. (codex instead ignores an untrusted
project's config and lets a trusted one loosen anything; keke has no project
trust store for configuration, and tighten-only needs none.)
An editor speaking ACP can send mcpServers with session/new,
session/load and session/resume, and keke connects them like any other MCP
server. initialize advertises what is accepted: stdio and streamable HTTP in
both protocol versions, and HTTP+SSE in v1 (v2 has no SSE).
- Per session. The servers sent with a request belong to that session. On load or resume the servers in that request are used, not the ones the original session had.
- Tool names. Tools appear as
acp:<server>:<tool>. - Not trust-gated. A plugin under the workspace is withheld until a person approves it, because the repository chose it. A client's servers are chosen by the program that launched keke, not by the repository, so the workspace trust gate does not apply and there is no approval step.
- Name collisions are errors. Two client servers with one name, or a client
server named like an enabled configured (plugin or
keke mcp add) server, fail the request with an error naming the server. An empty name, command or url is an error too. keke never picks one silently. - Values may use
${VAR}references, expanded at spawn or request time as for configured servers.
Persistent, per-agent memory: markdown notes the model reads and writes across sessions. Off unless a directory is named.
[memory]
dir = "/var/lib/bots/ada/memory" # absolute; unset = memory off
summary_max_bytes = 8192 # 0..=65536; 0 injects no summary
entry_max_bytes = 32768 # 1024..=1048576; larger writes are refused--memory-dir <PATH> (env KEKE_MEMORY_DIR) overrides dir for one run, in
the TUI, keke resume, keke exec, and keke agent stdio alike. A relative
path is resolved against the process's current directory. dir in a file must
be absolute, and a repository's .keke/config.toml may not set it.
Use one directory per bot: a host running several agents (Codexia, for
example) passes each its own --memory-dir. Each entry is <dir>/<name>.md,
where name is lowercase letters, digits, - and _ (up to 64 characters,
starting with a letter or digit). The model gets memory_read and
memory_write; neither needs approval.
The system prompt carries a short summary — the entry names and first lines,
cut to summary_max_bytes — taken once when the session starts. Writes
during the session do not change it (that keeps the prompt cache warm), but
memory_read always sees the current files and the next session's summary
includes them.
sandbox_mode = "read_only" governs the workspace and the commands the model
runs. Memory is the agent's own state, outside the workspace, so the memory
tools keep working under it.
Configuration is loaded from multiple layers, with later layers overriding earlier ones:
- Built-in defaults (compiled in)
- User config —
$KEKE_HOME/config.tomlor~/.keke/config.toml - Project config —
.keke/config.tomlin the workspace root - Environment variables —
KEKE_*prefixed (e.g.,KEKE_APPROVAL_POLICY=never)
[skills] disabled accumulates across layers too. Provider declarations accumulate across layers and are keyed by route, so redeclaring a provider replaces that entry rather than the whole set. [[dir]] entries accumulate the same way, and are applied on top of the merged layers — above every file, below anything typed on the command line.
view_image and images attached in the terminal use the same validated budgets:
[images]
read_bytes = 20971520
decoded_pixels = 40000000
max_dimension = 2048
encoded_bytes = 3145728read_bytes bounds the source file before decoding; decoded_pixels bounds
its decoded pixel count. Images exceeding max_dimension are resized while
preserving aspect ratio, and output must fit encoded_bytes before base64
encoding. Invalid files fail before entering a model request. These limits
apply to newly loaded images, including files outside the workspace that the
process can read.