Configuration Reference

Openheim loads its configuration from ~/.openheim/config.toml. Generate a default file with:

openheim init

A key openheim doesn't know, such as a typo like allow-shell for allow_shell, is ignored with a warning on stderr naming it (ignoring unknown config key `allow-shell` ). It isn't an error, so a config written for a newer version still loads. Set RUST_LOG=error to silence the warnings.


Top-level fields

Field Type Default Description
default_provider string — Provider to use when no --model override is given (must match a key under [providers])
max_iterations integer 10 Maximum number of agent loop iterations per prompt before stopping
default_skills string[] [] Skills loaded automatically in every new session. Merged with per-session --skills; defaults appear first, duplicates removed.
work_dir path cwd at invocation Root directory the agent is allowed to read and write. The agent cannot access files outside this tree. When unset, defaults to the directory from which openheim was invoked. A relative path is taken from that directory too (no ~ expansion). It's resolved at startup, symlinks followed, and must be an existing directory, or openheim refuses to start.
allow_shell boolean false Whether to expose the execute_command shell tool to the LLM. Disabled by default — set to true to expose the tool. When false, the LLM never sees it in its tool list.
data_dir path ~/.openheim Root directory for openheim's own data: conversation history (history/), skills (skills/), system.md, subagent profiles (agents/), and the long-term memory database (memory.db, unless [memory].db_path overrides it). The config file itself is still read from ~/.openheim/config.toml. Lets two agents in one process keep separate state, or a sandboxed run stay out of the real home directory.
default_provider = "anthropic"
max_iterations = 20

# Always load these skills without passing --skills each time
default_skills = ["rules", "concise"]

# Restrict the agent to a specific directory tree
work_dir = "/home/user/projects/myproject"

# Enable shell command execution (disabled by default)
# allow_shell = true

# Keep openheim's own data (history, skills, memory) out of $HOME
# data_dir = "/var/lib/openheim"

Security notes

work_dir is enforced at the application layer for read_file, write_file, edit_file, list_dir, and search, and for the /ws filesystem sidecar (all fs-channel operations are validated against the same boundary). Within it, each session's tools work in the session's cwd (ACP session/new, SessionBuilder::cwd, or the directory openheim was started from) when that is inside work_dir: relative paths resolve there and execute_command runs there. A cwd outside work_dir is ignored and work_dir is used instead; it never widens what's reachable. Symlinks are followed and canonicalized so they cannot be used to escape the boundary. Shell commands (execute_command) are launched with work_dir as their working directory so relative paths resolve correctly, but absolute paths inside a shell command are not blocked — OS-level sandboxing (chroot, containers) is required for full shell isolation. Shell commands are additionally bounded: each runs in its own process group, is killed after a 120-second timeout (or on turn cancellation, or when the openheim process gets SIGINT, SIGTERM or SIGHUP), and has stdout/stderr capped at 64 KiB per stream with a truncation marker.

Every tool result, built-in, MCP or custom, is cut to 128 KiB (with a note saying how much was left out) before it goes into the conversation, since the history is resent with every request.

web_fetch is not subject to work_dir — it fetches remote URLs, not local files. It's bounded instead by an SSRF guard (rejects every address that isn't publicly routable unicast: loopback, private, shared/CGNAT, link-local including cloud metadata endpoints, multicast, reserved and documentation ranges, and NAT64/6to4/Teredo addresses that lead to them), a 20-second timeout, a 256 KiB response cap, and no automatic redirect following.

allow_shell gates whether execute_command appears in the tool list sent to the LLM. It defaults to false — the LLM never sees the tool and cannot request it. Set it to true to expose the tool (bounded as described above).


[providers.<name>]

Each key under [providers] defines a provider. The key name is the provider identifier (e.g. "openai", "anthropic", "ollama"). Which API client talks to it comes from kind, which is inferred from the name when omitted, so the name can be anything (e.g. two Anthropic accounts as [providers.claude-work] and [providers.claude-personal], both with kind = "anthropic").

Field Type Required Description
kind "openai" | "anthropic" | "gemini" | "openai_compatible" No Which API the provider speaks. Omitted = inferred from the name: openai, anthropic and gemini get their own kind, any other name is "openai_compatible".
api_base string Yes Base URL for the API
default_model string Yes Model used when none is specified for this provider
models string[] Yes List of available models (used for validation and the /api/models endpoint)
env_var string No Name of the environment variable holding the API key (recommended)
api_key string No Inline API key — env_var takes precedence if both are set
timeout_secs integer 120 Connect and idle-read timeout in seconds — bounds the connect phase and the maximum gap between body reads, not total request duration, so long streaming responses are not cut off mid-stream
max_tokens integer No Maximum output tokens per response (provider default if omitted). An openai-kind provider sends it as max_completion_tokens (OpenAI's reasoning models reject max_tokens); an openai_compatible one sends max_tokens, which most compatible backends still expect.
context_window integer No The models' context window in tokens (e.g. 200000). A request estimated at over 90% of it leaves out the oldest whole turns before it's sent. Without it, older turns are left out only after the provider rejects a request as too long, which costs one failed request per turn once a session gets that long. Either way the saved history keeps everything; see Fitting the context window. Applies to every model under this entry, so use the smallest window among them.
thinking "adaptive" | "off" "adaptive" for an Anthropic-kind provider, "off" otherwise Extended thinking. Only the Anthropic client reads this — other providers ignore it. Applies to every model under this entry, so set it to "off" if default_model/models includes one that doesn't support adaptive thinking (e.g. claude-haiku-4-5, claude-3-7-sonnet) — use a second [providers.<other-name>] entry with kind = "anthropic" for that model if you need both.

Key resolution order: env_var (if set and non-empty) → api_key → empty string (for keyless providers like Ollama).

Examples

[providers.openai]
api_base = "https://api.openai.com/v1"
default_model = "gpt-4o"
models = ["gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo"]
env_var = "OPENAI_API_KEY"
timeout_secs = 120
max_tokens = 4096
context_window = 128000

[providers.anthropic]
api_base = "https://api.anthropic.com/v1"
default_model = "claude-sonnet-4-6"
models = ["claude-sonnet-4-6", "claude-opus-4-7"]
env_var = "ANTHROPIC_API_KEY"
# thinking = "off"  # both models above support adaptive thinking, so the "adaptive" default applies; set "off" here instead if any model in `models` doesn't

[providers.gemini]
api_base = "https://generativelanguage.googleapis.com/v1beta"
default_model = "gemini-3.8-flash"
models = ["gemini-3.8-flash", "gemini-3.5-flash-lite"]
env_var = "GEMINI_API_KEY"

# OpenAI-compatible local model — no API key needed
[providers.ollama]
api_base = "http://localhost:11434/v1"
default_model = "llama3"
models = ["llama3", "mistral", "codellama"]

[mcp_servers.<name>]

Each key under [mcp_servers] connects an MCP server. The key name becomes the tool-name prefix: a tool named read_file on a server named filesystem is exposed as filesystem__read_file.

Use either command (stdio transport) or url (Streamable HTTP transport), not both.

Field Type Required Description
command string Stdio only Binary to spawn (e.g. "npx", "uvx")
args string[] No Arguments passed to command
env table No Extra environment variables for the spawned process
url string HTTP only Base URL for Streamable HTTP transport
headers table No Extra HTTP headers sent with every request, e.g. auth (HTTP only)
tool_timeout_secs integer No Longest one tool call may take, including any wait for a reconnect, before it fails with a timeout error. At least 1. Default: 600

If a server's connection closes (a stdio server exits, an HTTP server drops it), the next call to one of its tools reconnects. A call that was in flight when it closed fails and isn't retried, since the tool may already have run. If reconnecting fails, calls to that server fail with the same error for 30 seconds before the next attempt.

env and headers are inline tables, so a server with credentials still fits on one [mcp_servers.<name>] block — no separate [mcp_servers.<name>.env] section needed:

# stdio — spawn a local process
[mcp_servers.filesystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]

# stdio with env vars (child-process environment)
[mcp_servers.my-db]
command = "uvx"
args = ["mcp-server-postgres"]
env = { DATABASE_URL = "postgresql://localhost/mydb" }

# Streamable HTTP, no auth
[mcp_servers.remote-tools]
url = "http://localhost:8080/mcp"

# Streamable HTTP with auth — headers is the HTTP equivalent of env,
# same inline-table shape, applied to what an HTTP server actually
# consumes (a request header, not a process env var)
[mcp_servers.firecrawl]
url = "https://api.firecrawl.dev/mcp"
headers = { Authorization = "Bearer my-key" }

headers is rejected over a plain http:// URL — credentials must not be sent unencrypted; use https:// or drop the headers for a keyless local server.

The name key is sanitized when building tool names: anything but ASCII letters, digits and underscores (hyphens and spaces included) becomes an underscore. So my-db → my_db__query_table. The tool's own name keeps hyphens but is sanitized the same way otherwise, so fs.read is exposed as files__fs_read. A full name starting with a digit gets a leading _, and one longer than 64 characters is shortened and ends in a hash of the original, so every provider accepts it.

Servers start concurrently when openheim starts. One that hasn't connected and listed its tools within 60 seconds is reported as failed (see GET /api/mcp-servers) and its tools are left out; the others are unaffected.


[memory]

Optional, as is every field in it. The agent always has the remember, search_memory, edit_memory, and forget tools (with the rag cargo feature, on by default for the binary; library users with default-features = false add features = ["rag"]). Nothing is stored or retrieved unless the model calls them. Without an embedding provider, search_memory is keyword search over SQLite's FTS5 index — no network, no API key. Set embedding_provider and embedding_model to make it semantic.

Field Type Default Description
embedding_provider string unset A [providers.<name>] entry whose api_base and API key serve the embeddings endpoint. A Gemini-kind provider speaks Gemini's batchEmbedContents; any other kind is OpenAI-compatible /embeddings (OpenAI, Ollama, Together, …). Anthropic-kind providers are rejected, since Anthropic has no embeddings API. Unset means keyword search.
embedding_model string unset Embedding model, e.g. text-embedding-3-small, gemini-embedding-001, nomic-embed-text. Required when embedding_provider is set.
db_path path ~/.openheim/memory.db SQLite file holding notes, the FTS5 index, and vectors. Absolute path; ~ is not expanded.
top_k integer 5 Default number of notes a search_memory call returns (the model may ask for up to 20)
[memory]
embedding_provider = "openai"
embedding_model = "text-embedding-3-small"

Notes are capped at 4000 characters each. Enabling embeddings later back-fills vectors for existing notes, and changing embedding_model (or a model returning a different vector size) re-embeds every note, so switching is safe. The back-fill embeds up to 256 notes per memory tool call, so a large store catches up over several calls. Until a note has a vector, search_memory finds it by keyword: those matches are listed first, and the result says how many notes aren't indexed yet.

Memory keeps working when the embeddings provider doesn't. Rate limits (429), server errors (500, 502, 503, 504), timeouts and failed connections are retried twice with a short backoff. If embedding still fails, remember and edit_memory save the note without a vector (it's keyword-searchable at once and embedded by a later back-fill), and search_memory falls back to keyword search and says so in its result, even when nothing matched. A failing back-fill is logged and retried on a later call. A note the provider refuses outright (not a transient error) is skipped by the back-fill until openheim restarts, so it can't hold up the others; it stays keyword-searchable.

In architect mode only search_memory is available; remember, edit_memory, and forget are treated as writes.


[tui]

Optional, as is the field in it.

Field Type Default Description
theme_color string "gray" TUI accent color. Valid values: white, gray, blue, cyan, magenta, green, yellow, red, pink. Can also be changed at runtime with :theme, which persists the choice back to this section.
[tui]
theme_color = "blue"

Complete example

default_provider = "anthropic"
max_iterations = 15

# Restrict the agent to this directory tree
work_dir = "/home/user/projects/myproject"

# Enable shell command access (disabled by default)
# allow_shell = true

[tui]
theme_color = "blue"

[providers.anthropic]
api_base = "https://api.anthropic.com/v1"
default_model = "claude-sonnet-4-6"
models = ["claude-sonnet-4-6", "claude-opus-4-7"]
env_var = "ANTHROPIC_API_KEY"

[providers.openai]
api_base = "https://api.openai.com/v1"
default_model = "gpt-4o"
models = ["gpt-4o", "gpt-4-turbo"]
env_var = "OPENAI_API_KEY"

[providers.ollama]
api_base = "http://localhost:11434/v1"
default_model = "llama3"
models = ["llama3", "mistral"]

[mcp_servers.filesystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]

Custom config path

To load a config from a non-default path (useful when embedding openheim as a library):

let client = OpenheimClient::from_config("/etc/myapp/openheim.toml")
    .build()
    .await?;

See Library Usage for the full library API.