Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Config file

meka looks for a TOML configuration file at a platform-specific location:

PlatformPath
Linux~/.config/meka/config.toml ($XDG_CONFIG_HOME/meka/config.toml)
macOS~/Library/Application Support/meka/config.toml
Windows%APPDATA%\meka\config.toml

The config file is optional. If it does not exist, meka silently skips it.

meka refuses unknown keys: a typo (contex_window) or a removed key (reasoning_effort) fails the load with an error naming the offending key, rather than being silently ignored. Fix or remove the key to continue.

The commands that edit the file are exempt, so a broken config can still be repaired from the CLI: meka mcp add / remove / enable / disable, meka account remove / rename and meka profile remove / rename work on the raw document and don’t care about an unknown key elsewhere in it. Everything that reads config fails instead of answering from empty defaults, because “No MCP servers.” over a file full of them is indistinguishable from the truth.

Those editors only reach the keys they own, so a bad key anywhere else ([session], [permissions], a top-level typo, a raw syntax error) has to be fixed in an editor. The error names the file, line, column, and offending key.

Set the MEKA_CONFIG_DIR environment variable to override the default location entirely. The value points at the meka directory itself (contains config.toml and skills/). Useful for tests, portable installs, and isolating a per-project config from your global one.

The directory holds two more things that are not config keys. Standing instructions live at a conventional path beside the file, because prose long enough to be worth writing is miserable to maintain inside a TOML string, and skills have a directory of their own:

~/.config/meka/
├── config.toml
├── instructions.md      # or instructions/*.md
└── skills/

Write instructions.md, or split a large set across instructions/*.md, and meka reads it at startup into the ## Standing instructions section of the system prompt; see Instructions. To pass the text as a string instead (containers, CI), use MEKA_INSTRUCTIONS, MEKA_INSTRUCTIONS_FILE, or --instructions. Files beyond that path, a project’s AGENTS.md for one, are named under [instructions].

Everything in that directory is content you put there, so it is safe to keep under version control. Commands that edit the config take a cross-process lock on the directory itself, as does claiming a skill store, so neither leaves a lock file behind; a write is published by renaming a short-lived config.toml.<pid>.<seq>.tmp over the target, so that name can appear for the duration of one write. If you are upgrading from a version that wrote .config.toml.lock, or .meka-store.lock inside a skill store, delete them: nothing reads or writes them any more.

Windows still writes both, because its file locks are mandatory rather than advisory: a lock held on config.toml would make the file unreadable to the command holding it, and LockFileEx refuses a directory handle outright. If you keep a meka config directory under version control on Windows, ignore .config.toml.lock and skills/.meka-store.lock.

The sections below are in the order a file is best written in: the blocks with many entries first (default_profile, accounts, profiles, MCP servers), then each single table from the most to the least consequential, and [serve] last.

Accounts and profiles

Where a request goes and what it asks for are configured separately. An account is a backend, an endpoint and the credential a login produced: [accounts.<name>] in config.toml, with the secret in the store under the same name. A profile is an account plus a model and every model-tied setting: [profiles.<name>]. A session records the profile it runs on; the profile names its account. Two profiles on one account share one login; one account on two endpoints is two accounts.

Secrets are never stored in the config file. API keys and OAuth token bundles live in the store, keyed by account name, and are acquired through the meka account command suite (meka account add runs the API-key prompt or the OAuth login for you). The config file holds only the non-secret settings shown below.

default_profile = "work"

[accounts.claude]
backend = "claude-subscription"

[accounts.ollama]
backend  = "openai-chat-completions"
base_url = "http://localhost:11434/v1"

[profiles.work]
account = "claude"
model   = "claude-opus-5-5"

[profiles.fast]
account        = "claude"
model          = "claude-haiku-5-5"
context_window = 200000

[profiles.local]
account = "ollama"
model   = "llama3"

Selecting the active profile

For each run meka picks one profile using this precedence:

  1. --profile <name> CLI flag.
  2. default_profile in the config file.
  3. The sole profile, if exactly one is configured.

If none of these resolve (no profiles configured, or more than one with no default_profile / --profile), meka errors and points you at meka profile add / meka profile use. Resuming a session is the exception: it runs on the profile it recorded and never consults this, so an ambiguous default does not block meka -c. meka serve is the other: it starts, and refuses the POST /v1/sessions that names no profile with the same sentence, since each of its sessions may name its own. There is no environment-variable tier for profile selection; the config file (plus the per-run CLI flag) is the source of truth.

Timeouts

Every backend connects with a 30-second handshake deadline and fails a stream that produces nothing for five minutes, which surfaces as a retryable error rather than a hung turn.

Neither is a limit on the turn. There is deliberately no cap on how long a turn may run, how many tool calls it may make, or how many tokens it may spend: those ceilings belong to your API key and your provider plan, not to the harness. What these bound is silence. A model that is still thinking is still sending, so a stream that goes quiet for five minutes has died, and waiting on it forever is not patience.

A whole reply (--no-stream, and the summary a compaction asks for) is silent until it is finished, so it gets no clock at all: a reply may take as long as it takes. A connection whose peer has gone is found by TCP and HTTP/2 keepalives instead, which a busy server answers and a vanished one does not, so a dropped route still surfaces as an error within a minute or two. A peer that is up and never answers is not found this way: such a request waits until it is canceled, which is the trade a base_url behind a gateway with no timeout of its own makes. A cancel drops a pending reply at once, in either mode.

default_profile

Top-level field naming the profile to use when --profile isn’t passed. Set it with meka profile use <name>. A sole profile is the default by the selection rule, so the first meka profile add leaves the key unset; the second names the profile that was alone, so the default stays where it was.

Account fields

backend

The driver the account uses (required).

ValueProtocolAuth
anthropic-messagesAnthropic Messages, POST {base}/v1/messagesAPI key (x-api-key)
claude-subscriptionAnthropic Messages, against api.anthropic.comClaude subscription OAuth (fingerprinting + attestation)
openai-chat-completionsOpenAI Chat Completions, POST {base}/chat/completionsAPI key
openai-responsesOpenAI Responses, POST {base}/responsesAPI key
chatgpt-subscriptionOpenAI Responses, against chatgpt.com/backend-api/codexChatGPT subscription OAuth

An API-key backend is named for the protocol it speaks, because base_url decides the endpoint and the same protocol is served by many vendors. A subscription backend is named for the product, because the endpoint is fixed and what the account holds is a billing relationship. See Providers overview for which servers implement which protocol.

base_url

Custom API base URL. Useful for:

  • Self-hosted models via Ollama (http://localhost:11434/v1)
  • OpenRouter (https://openrouter.ai/api/v1)
  • Other OpenAI-compatible API providers

If not set, defaults to:

  • https://api.openai.com/v1 for the openai-chat-completions and openai-responses backends
  • https://chatgpt.com for the chatgpt-subscription backend (request path is /backend-api/codex/responses)
  • https://api.anthropic.com for the anthropic-messages and claude-subscription backends

Set it with meka account add <name> --base-url <url>, or edit the account table by hand.

The URL is used as given. An endpoint that answers with a redirect is refused, naming the location it pointed to, rather than followed, so a credential never leaves the host you configured; set the final URL.

The two API families end their base URL in different places, and that is not meka’s choice. An OpenAI-compatible base includes the version segment, which is why every provider documents one ending in /v1 and why meka appends only /chat/completions. A Claude base is the host root, because meka reaches two different roots off it: /v1/messages for the turn, and /api/oauth/... for the subscription usage and profile endpoints. A base ending at /v1 could not reach the second set. The official SDKs draw the line the same way.

A gateway that fronts both APIs therefore publishes two URLs, and its Anthropic one is often written with the /v1 its OpenAI sibling needs (https://api.synthetic.new/anthropic/v1). Paste it as-is: for an anthropic-messages or claude-subscription account meka drops a trailing /v1, since it re-adds that segment on every request and the alternative is a request to /v1/v1/messages. Only a trailing one goes, so a base whose path legitimately contains /v1 earlier (https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic) is left alone. Trailing slashes are trimmed for every backend.

The reverse is not inferred: an openai-chat-completions base is used exactly as written, because a gateway serving /chat/completions at its root is legitimate and meka cannot tell that apart from a missing /v1. If an OpenAI-compatible endpoint 404s, check that the base carries the version segment its documentation shows.

oauth_token_url

The OAuth token endpoint meka posts to, for the initial code exchange at meka account add / login and for every refresh thereafter. Both, not just refreshes: it overrides a constant, so it overrides it everywhere that constant is used. Defaults:

  • https://platform.claude.com/v1/oauth/token for claude-subscription
  • https://auth.openai.com/oauth/token for chatgpt-subscription

It exists because that endpoint is the provider’s fact, not meka’s, and a value baked into the binary either goes stale or sits on the far side of a proxy your network makes you use. Set it with client_id when your route out needs both.

There is deliberately no authorize_url to go with it, and the asymmetry is not an oversight: meka never requests the authorization URL, it hands it to your browser, so an egress proxy is never in that path. The two legs meka makes itself are the code exchange and the refresh, and this covers both.

client_id

OAuth client id override (advanced; claude-subscription / chatgpt-subscription only). Leave unset to use meka’s built-in default client ids.

interleaved_thinking

Whether the endpoint takes the interleaved-thinking beta header, which anthropic-messages sends whenever thinking is on. Unset is true, which is what Anthropic’s own API needs to think between tool calls; an endpoint that refuses the header sets it false. The other backends never read it, and meka account add --interleaved-thinking refuses it for them. The other switch an endpoint decides, the cache breakpoint, is a profile field, because its answer varies by model on a gateway; see cache_control.

[accounts.gateway]
backend = "anthropic-messages"
base_url = "https://gateway.example.test"
interleaved_thinking = false

prompt_cache_key

openai-responses only. Whether the request carries prompt_cache_key, OpenAI’s hint for routing one conversation’s requests to the cache holding its prefix; meka sends the session’s id, as chatgpt-subscription does. Unset is true, which is what OpenAI’s own API takes. An endpoint that refuses the field sets it false; most servers ignore a field they do not know.

stream_usage

openai-chat-completions only. Whether a streamed request asks for its usage with stream_options.include_usage, without which OpenAI’s own API reports none and the context gauge and automatic compaction read zero. Unset is true. An endpoint that refuses the field sets it false, and its usage is then whatever it reports unasked (OpenRouter reports it either way).

[accounts.strict]
backend      = "openai-chat-completions"
base_url     = "https://llm.example.test/v1"
stream_usage = false

max_tool_name_bytes

anthropic-messages, openai-responses and openai-chat-completions only. The longest tool name the endpoint takes, in bytes. Unset is the wire’s own limit as its owner documents it: 128 on the Messages and Responses APIs, 64 on Chat Completions. A namespaced MCP tool name longer than this is sent cut to it, ending in a hash of the whole name, and read back as the whole name. A gateway that holds names to less states what it holds them to; at least 16, which a cut name needs.

device_id

claude-subscription only. Stable per-device identifier embedded in metadata.user_id to mirror Claude Code’s ~/.claude.json device id (getOrCreateUserID in utils/config.ts).

If unset, meka first tries to adopt userID from ~/.claude.json (so meka and Claude Code on the same machine look like the same device). If that file is missing or has no userID, meka generates a 64-character hex string. Either way, the resolved value is persisted back to the account under [accounts.<name>].device_id, the first time a process reads the account: at startup, or when meka serve serves the file after a write or a reload. This file write only happens for the claude-subscription backend; other backends don’t need a device id.

You can supply your own value if you want to control attribution explicitly:

[accounts.work]
backend   = "claude-subscription"
device_id = "your-stable-id-here"

Profile fields

account

The account the profile bills (required). Must name an [accounts.<name>] table; a profile whose account is missing is refused by name when a session tries to run on it, and meka profile list says so.

model

The model identifier to send to the provider, forwarded verbatim. Optional in the file, but a session cannot run without one: a profile that names no model is refused by name when a session tries to run on it. meka does not gate which strings are valid, so an OpenAI-compatible endpoint accepts whatever that server exposes.

meka profile add suggests claude-opus-5-5 for a profile on a Claude account and gpt-6-astra for one on an OpenAI account. For the current line-ups, see Anthropic’s models overview and OpenAI’s models overview; naming them here would go stale on someone else’s schedule.

Change it with meka profile set <name> model <value>.

fallbacks

Profiles to serve a turn from when this one cannot, in order of preference: list the profile you would rather run on first, since every turn starts on the first profile, this one included, whose upstream is not known to be down, and a switch rebuilds the prompt cache at the new profile once. A turn falls back when the profile’s upstream is still failing once the retry budget is spent (an outage, a rate limit, a dropped stream), or when it refuses for quota or billing by a signal it documents (a 402, a quota code on a 429, the Claude subscription’s exhausted window). It does not fall back on a context overflow, an invalid request, a tool refusal or an authentication failure, which another profile would not fix. The whole profile is swapped, so no setting of this one is ever mixed with another’s. Each fallback must be able to carry the sessions of the profile it backs: the same vision, and a context_window and max_request_bytes no smaller, each resolved as a session resolves it. A larger window or cap on a fallback is never drawn on: while a fallback serves, the session keeps gauging against its own profile’s window, so when it compacts does not depend on who answered.

The session keeps naming this profile. A fallback serves one turn, the rest of that turn stays on it, and the next turn starts here again, or on the first fallback whose upstream is not known to be down: a profile that failed over is skipped for five minutes, or until the upstream’s stated reset when that is sooner, never longer. Once a turn is running, a failure moves it to the next profile in the list it has not tried, so it fails only when every one has refused it. What served a turn is on its record (profile on GET /v1/sessions/{id}/turns and on turn.finished) and in a notice on the feed and the screen. See Fallback profiles.

Each entry must name another configured profile, once. meka profile remove refuses a profile another’s fallbacks names, and meka profile rename moves the name.

[profiles.main]
account   = "anthropic"
model     = "claude-opus-5-5"
fallbacks = ["backup"]

[profiles.backup]
account = "openrouter"
model   = "anthropic/claude-sonnet-4-5"

Change it with meka profile set <name> fallbacks backup,last-resort (comma-separated), or --unset to remove it. meka profile add takes --fallback <name>, repeatable.

context_window

The model’s context window (total tokens it can hold), used for the /status gauge and auto-compaction. Takes precedence over [session].context_window; when neither is set, meka assumes 1000000.

meka never infers this from the model name and never asks the provider for it, so this is where a model smaller than the default gets stated. It is a local budgeting number that is never sent on the wire, so a wrong value can’t fail a request, but leaving it at 1M for a smaller model means planned compaction never fires, and every compaction instead happens after the provider rejects the request as too large, costing a wasted round trip each time.

The window belongs to the session, not to the process: each session is measured against the profile it recorded, so two sessions in one meka serve can sit on profiles with different windows.

[profiles.work]
account        = "openai"
model          = "my-128k-model"
context_window = 131072

max_output_tokens

Override the per-request output (completion) token cap. When unset, each backend keeps its built-in default:

BackendDefault when unset
Claude, thinking adaptive128000
Claude, budgetedtwice the resolved budget, or 32000, whichever is larger
Claude, off32000
Every other backendthe endpoint’s own

The Claude figures are meka’s own defaults for the two Anthropic backends. The adaptive one is what Claude Code 2.1.310 sends for Opus 5.5, the model meka profile add suggests, and the most any model that takes adaptive thinking accepts; Claude Code’s per-model catalog says the same for Sonnet 5.5 and Haiku 5.5 and 64000 for the rest of the line-up, and stating a figure here replaces the default. The OpenAI backends send no cap unless the profile states one, because each reaches whatever base_url names and the endpoint’s default is that endpoint’s fact.

Under thinking = "budgeted" the value must exceed the profile’s resolved thinking budget (thinking_budget, else [thinking].budget, else 16000). meka profile add and meka profile set both refuse a profile that fails this, and it is validated again at startup.

[profiles.work]
account           = "anthropic"
max_output_tokens = 16000

max_request_bytes

Largest request body, in bytes, before the oldest tool-result images are redacted to fit; a body that still does not fit is refused, and the turn retries without its newest attachments. Unset, the Anthropic backends use 31457280 (30 MiB), which is Anthropic’s 32 MiB cap less headroom, and the OpenAI backends apply no ceiling until one is stated: their endpoints’ caps are the endpoints’ own facts. An account reaches whatever its base_url names, so a profile on an endpoint with a smaller cap states it here. Redaction removes tool-result images, oldest first; an image attached to the newest message is never removed, and a body that still does not fit is refused so the turn can degrade its own attachments instead. openai-chat-completions never sends tool-result images (that API’s tool messages are text), so there the ceiling only refuses. The Anthropic backends also send at most 100 images in one request, the Messages API’s own limit, redacting the oldest past it the same way.

[profiles.local]
account           = "gateway"
max_request_bytes = 8388608

effort

One knob for reasoning effort across every backend: Claude sends it as output_config.effort (claude-subscription under the effort-2025-11-24 beta, anthropic-messages directly), openai-chat-completions as reasoning_effort (with max_completion_tokens for the output cap), and the two Responses backends as reasoning.effort (with max_output_tokens).

When unset the field is omitted, and the provider applies its own default. claude-subscription is the exception: it sends medium, Claude Code’s default for Opus 5.5. That is the point of leaving it unset: effort is a request parameter the provider owns, and omitting it is how you ask for whatever that provider considers right. meka picks no tier of its own, because it cannot know which tiers a given endpoint implements: anthropic-messages and openai-chat-completions reach any compatible server, including local ones serving weights that never had a reasoning knob, and a tier the backend doesn’t implement is a rejected request rather than a graceful ignore.

An explicit value is absolute: sent verbatim (trimmed and lowercased), with no validation or clamping, whatever model it is aimed at. You own correctness for your model and endpoint; an invalid value is rejected by the API. A blank value reads as unset.

Typical values: low, medium, high, xhigh, max.

[profiles.work]
account = "anthropic"
effort  = "xhigh"

vision

Whether this profile’s model accepts image input. Defaults to true. Set false for a text-only model so attachments are refused rather than sent to a model that cannot read them. A turn on such a profile is refused, before anything is sent, while the session’s history holds an image: switch the session to a profile with vision. A profile’s fallbacks share its vision, so a fallback never meets that refusal.

Refusal is per session, from the profile that session recorded, on both ACP and POST /v1/sessions/{id}/turns. What ACP advertises in promptCapabilities.image is necessarily per connection: initialize is answered before any session exists, so it reports the default profile’s flag. A client on a vision-capable connection can still have its attachment refused by a session pinned to a text-only profile. See ACP.

[profiles.local]
account = "ollama"
model   = "llama-3-8b"
vision  = false

deferred_tools

How a request carries the tools the agent has not loaded: native or append. Every backend but openai-chat-completions reads it. Under append a deferred tool is absent from the request until tool_load adds its schema to the tools array; the array heads the cached prefix, so the request after a load rewrites the whole cache once. Under native every deferred tool is declared from the first request with defer_loading, the array never changes, and tool_load and tool_search answer with a reference the endpoint expands in place: Anthropic’s tool_reference block on anthropic-messages and claude-subscription; on openai-responses and chatgpt-subscription the endpoint’s own tool_search item, which replaces the two discovery tools there and takes a query to find or a list of names to load. Unset is native on the two subscription backends and on anthropic-messages and openai-responses without a base_url, and append elsewhere: a gateway that ignores defer_loading accepts it silently and shows the model every deferred schema from the first request, which costs more than append and is invisible. Whether a gateway honors it varies by model and by the upstream it routes a request to, so check a profile before setting native on it: a deferred tool called with the right arguments and no load means the schema was shown. See Deferred tools for the cost model and for deferral, the registry setting that decides which tools are deferred at all.

[profiles.claude-via-openrouter]
account        = "openrouter"
model          = "anthropic/claude-opus-5-5"
deferred_tools = "native"

thinking

Claude-only. How the request encodes extended thinking, and whether it asks for it at all:

ValueWire shape
adaptive (default)thinking: {"type": "adaptive"}: the model sets its own budget. Claude 4.6+
budgetedthinking: {"type": "enabled", "budget_tokens": N}, with N from thinking_budget, else [thinking].budget, else 16000. Required by pre-4.6 Claude, and the form most third-party Anthropic-compatible servers implement
offNo thinking field

One knob rather than two: it replaces both the old on/off switch and the encoding meka used to infer from the model name. The right value depends on the model and on what the endpoint implements, which meka can’t determine, so the profile states it, and a profile whose model later changes is yours to keep correct.

[profiles.local]
account  = "gateway"
thinking = "budgeted"

thinking_budget

Tokens the model may spend thinking. Read only under thinking = "budgeted"; the other two settings send no budget at all. A profile that states none falls back to [thinking].budget, and then to 16000.

Per profile because it is a parameter of thinking, and thinking is per profile. It was one installation-wide value until 0.44, which meant a profile could be refused over a number stated nowhere in it, and told to fix it by lowering a global every other profile was also budgeting against. Under thinking = "budgeted" this profile’s max_output_tokens must exceed the resolved budget, and the remedy now names this profile’s own keys.

[profiles.work]
account         = "anthropic"
thinking        = "budgeted"
thinking_budget = 20000

cache_control

anthropic-messages only. Whether each request carries the moving cache_control breakpoint on the last block it sends. Unset is true, which is what Anthropic’s own API needs to cache the prompt incrementally: the breakpoint moves with the conversation, and each turn is billed a write for the new suffix alone. What an endpoint does with the breakpoint is the endpoint’s fact, and on a gateway it varies by model, which is why this sits on the profile rather than beside interleaved_thinking on the account. OpenRouter turns it into Gemini explicit caching, which bills a write of the whole prompt on every turn the breakpoint moves, so a Gemini profile there pays more with the breakpoint than without it. The symptom in the usage is a cache write equal to the cache read on every turn with no live input, and a /status gauge at twice the real occupancy, since the two tiers are counted as separate tokens. Such a profile sets false, which sends no breakpoint at all and leaves the endpoint’s own implicit caching to work; a Claude model on the same account keeps the default. The other backends never read it: claude-subscription pins its own breakpoint and the OpenAI wires have none, so meka profile add --cache-control is dropped for them and meka profile set refuses it.

[profiles.gemini]
account       = "openrouter"
model         = "google/gemini-3.8-flash"
cache_control = false

thinking_display

claude-subscription only. How the model’s thinking is presented, one of Claude Code’s three display modes:

  • summarized (the default): the server streams a short summary of the reasoning, shown as thinking text (a dimmed preview row, the whole summary when expanded). Sent as thinking.display = "summarized", which is what Claude Code sends with its showThinkingSummaries setting on.
  • updates (Claude Code’s own default): the server streams a running token count in place of the text, and the screen draws • Thinking · 150 tokens from it, redrawn as the count climbs and left on screen when the phase ends. Sent as thinking.display = "updates" under the thinking-display-updates-2026-08-18 beta.
  • redacted: the server withholds the text and may return opaque redacted_thinking blocks. Sent as the redact-thinking-2026-02-12 beta with no display field.

Every mode returns signed thinking blocks, which meka stores and replays verbatim, so multi-turn continuity holds in all three. With thinking off there is nothing to display, and meka sends the redaction beta as Claude Code does.

[profiles.work]
account          = "anthropic"
thinking_display = "updates"

encrypted_reasoning

openai-responses only. Whether the request asks for the model’s reasoning sealed, with include: ["reasoning.encrypted_content"], and sends it back on the turn’s later requests, so the model reasons on between its own tool calls rather than starting over at each. Defaults to true, what OpenAI’s own API carries for a reasoning model. A model or endpoint that refuses the field sets it false: OpenAI’s API refuses it for a model that does not reason, such as gpt-4.1 or gpt-4o, with Encrypted content is not supported with this model naming include. Reasoning goes back only to the endpoint that sealed it: a session that ran on another endpoint, chatgpt-subscription included, keeps its reasoning on that endpoint.

reasoning_summary

openai-responses only. Whether the request asks for reasoning.summary, the digest of the model’s reasoning meka shows as its thinking. Defaults to true. A model or endpoint that refuses the field sets it false, as a model that does not reason does on OpenAI’s API, and the thinking then shows only what the endpoint streams unasked (vLLM and Ollama stream their reasoning text either way).

[profiles.gpt41]
account             = "openai"
model               = "gpt-4.1"
encrypted_reasoning = false
reasoning_summary   = false

[mcp]

Which MCP servers to connect to, and what their tools are allowed to do. The MCP page covers the rest: the meka mcp command suite, where a server’s secrets live, the OAuth flows, the connection lifecycle, and the resource and prompt tools.

[mcp] top-level table

FieldPurpose
default_permissionFallback permission for MCP tools whose server didn’t advertise readOnlyHint and doesn’t have a permission override. Accepts "none", "read", "workspace", or "unrestricted"; any other value is refused at startup, naming the line. If unset the hardcoded fallback is "unrestricted" (strict). It stays there deliberately: an MCP server runs unsandboxed, so workspace cannot confine it.
deferralDefault for every server’s deferral: deferred (the default), eager or hidden. A server’s own deferral wins, a name in its eager_load_tools ships eager under every value, and the MCP resource and prompt tools follow this setting.
default_requiredDefault for every server’s required flag. When true, all enabled servers gate the turn; when false (the default) only servers with required = true do. An unavailable optional server doesn’t stop the turn; its failure is logged once when it happens, and its live state is shown by /mcp list on the screen or probed with meka mcp reconnect <name>.
gracePer-turn cap on how long to wait for still-Pending servers to connect before deciding. A duration string; default "3s". "0s" skips the wait, for scripts that want to fail fast.
connect_timeoutPer-server timeout for connect + the protocol probe or initialize + list_tools. A hung stdio spawn or slow HTTPS handshake can’t stall the whole fleet past this bound. The server/discover probe a stdio server gets waits up to ten seconds, or half of this value when that is less, before the server is taken for legacy; an HTTP server’s probe waits up to ten seconds whatever this value is, so a value at or under ten seconds leaves an HTTP server that ignores the probe no time for the fallback. A duration string; default "30s". "0s" is refused at startup.
stdio_concurrencyHow many stdio servers connect at once at startup. Each is a process launch, so raising it trades startup latency for load; lower it on a machine where several heavy servers starting together is the problem. Default 3; 0 is refused at startup.
http_concurrencyHow many HTTP servers connect at once at startup. Higher than the stdio limit because a connect is a request rather than a process. Default 20; 0 is refused at startup.

[[mcp.servers]]

An array of MCP server configurations. Each entry defines a server to connect to at startup.

FieldRequiredDescription
nameYesUnique name for this server. Used as namespace prefix for tools (name__tool). Must match [A-Za-z0-9_-]+, must not contain __, and must not be meka, ide, or start with mcp_.
transportYesTransport type: "stdio" (spawn subprocess) or "http" (streamable HTTP).
commandStdio onlyPath or name of the executable to spawn. On Windows, npx / .cmd / .bat / .ps1 are auto-wrapped in cmd /c.
argsNoArguments to pass to the command.
envNoEnvironment variables to set for the spawned process (stdio only). The child does not inherit meka’s environment; see below.
urlHTTP onlyURL of the MCP server endpoint.
headersNoCustom HTTP headers to include with every request (HTTP only).
headers_helperNoPath to an executable whose stdout (Name: Value\n lines) is merged over headers on every connect (HTTP only), so a request the server rejects as unauthorized reconnects once to fetch them again. Absolute, or relative to the directory config.toml is in; a bare name is not searched on PATH. Executed with MEKA_MCP_SERVER_NAME / MEKA_MCP_SERVER_URL in env; 15 s timeout.
authNoOAuth authentication configuration (see below). Mutually exclusive with a stored bearer token.
permissionNoServer-wide permission override: none, read, workspace or unrestricted. Applies to every tool on this server, beating the readOnlyHint the server advertises and the [mcp].default_permission global fallback. Any other value is refused at startup, naming the line, the way an unknown key is. See Permission resolution below.
allowed_toolsNoOptional allow-list of raw tool names (the form the server advertises, not the server__tool namespaced form). When set and non-empty, only these tools are registered; all others from this server are ignored.
disabled_toolsNoOptional block-list of raw tool names. Applied after allowed_tools; tools listed here are never registered. Both lists can coexist; the net set is allowed_tools \ disabled_tools.
eager_load_toolsNoRaw tool names that ship eager-loaded whatever deferral says. Listed tools skip the tool_load round-trip and sit in the cacheable tools-array prefix from turn 1. Use this for tools the agent invokes constantly (search, fetch, …); leave others deferred so the tools array stays lean.
deferralNoHow this server’s other tools ship: deferred (listed under [Tool discovery], schema on tool_load), eager (every tool declared from the first request) or hidden (not registered at all: not in the catalog, the request or the context gauge). Unset inherits [mcp].deferral. See Deferred tools for what each costs.
tool_permissionsNoPer-tool permission overrides keyed by raw tool name, same values as permission. Beats the server-level permission and the server’s readOnlyHint when resolving a tool’s required permission. A level meka does not have is refused at startup, naming the line.
trust_read_only_hintNoWhether this server’s readOnlyHint: true may classify a tool as read. Defaults to true. Set false for a server you have not audited: its hints become advisory for display only, so its tools fall through to the strict unrestricted fallback, skipping [mcp].default_permission (a global convenience must not re-grant what a per-server audit decision refused). A readOnlyHint: false is still honored either way, since it only raises the requirement. See Permission resolution below.
disabledNoWhen true, the server is skipped entirely at startup: no process is spawned, no HTTP connect is attempted. Flip it back with meka mcp enable <name> or by editing the config. Unset means false.
requiredNoWhen true, a turn is refused while this enabled server is not Connected (a disabled server is never started, so it never gates). Over the HTTP API that refusal is a 503 /errors/mcp-unavailable naming the servers. When false, the session runs without it and its tools are simply absent. Unset inherits [mcp].default_required (itself false), so servers are optional unless they opt in.

Permission resolution

Every MCP tool’s required permission is resolved through a five-step chain; the first match wins:

  1. server.tool_permissions[<raw-tool>]: explicit per-tool override.
  2. server.permission: explicit server-level override. Applies to every tool on that server regardless of what the server advertises.
  3. tool.annotations.readOnlyHint from the server: true → Read, false → Unrestricted. The true half is skipped when the server sets trust_read_only_hint = false, and a hint skipped that way also bypasses step 4, landing on step 5.
  4. [mcp].default_permission: global fallback. Not consulted for a hint that step 3 refused.
  5. Hardcoded Unrestricted: strict ultimate fallback.

User-supplied config (1, 2, 4) always beats the server’s self-classification; if a server lies about a tool, you can override. But when no user config says anything, the server’s hint is trusted for that specific tool so readOnlyHint = false destructive tools don’t silently become Read-accessible just because the user opted into a lenient global default.

Hint spoofing: readOnlyHint is asserted by the server and not verified by meka, and MCP tools run in the server’s own process with no sandbox. A server that claims readOnlyHint = true for a tool that in fact writes therefore gets to write your tree while meka sits at read: MCP tools are outside the read filesystem boundary that covers meka’s built-ins (see Permissions).

Three defenses, in increasing order of bluntness:

  • tool_permissions on the specific tools you want pinned (step 1 wins).
  • trust_read_only_hint = false on the server, which makes its hints advisory for display only. A refused hint drops straight to the strict unrestricted fallback, deliberately skipping [mcp].default_permission: that key is a global default, and letting it answer would mean default_permission = "read" silently re-granting exactly what the per-server flag refused. None of that server’s hinted tools is reachable at read without an explicit override.
  • server.permission = "unrestricted" on the whole server (step 2 wins), or disabled_tools to remove the tool entirely.

The hint is trusted by default because most servers annotate honestly and requiring per-tool config for every server would make read impractical. trust_read_only_hint is the switch for a server you have not audited.

Stale config: entries in allowed_tools / disabled_tools / eager_load_tools / tool_permissions that don’t match any advertised tool get a warn! line at connect time. The server still connects; you just see a heads-up so you can clean up after the server renames a tool. A name that appears in both eager_load_tools and disabled_tools also warns: the disabled filter wins, so eager-loading the disabled tool is a no-op.

Visibility across levels: the resolved permission doesn’t hide a tool from the agent. Every registered tool is listed in the per-turn context with its required level noted inline, and a [Permission context] section names the current level and whether approvals are on (what each level allows is stated once in the system prompt, and the per-tool levels are in the catalog above it). The agent can still reason about an inaccessible tool and suggest /permission <level> to enable it; the permission gate is enforced at dispatch time. Keeping the tool catalog visible across levels is also what lets the Claude prompt cache survive mid-session permission toggles.

The stdio server’s environment

A stdio server is a child process that talks to the network, and it does not inherit meka’s environment. It receives the same curated base a shell at read gets (PATH so it can resolve its own binaries, HOME, locale, TMPDIR), plus whatever the server’s own env table sets.

Configuring a server is a decision to run its code, not a decision to hand it every credential on the machine: without this, ANTHROPIC_API_KEY, AWS_* and GITHUB_TOKEN all rode along into every server you had ever added.

The base also carries the machine’s network configuration (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, SSL_CERT_FILE, SSL_CERT_DIR, NODE_EXTRA_CA_CERTS and the usual siblings), because a server that cannot see them connects to nothing behind a corporate proxy and fails every call with an error naming none of the cause. Those say where to go and whom to trust; they grant nothing.

Three families are deliberately left out and have to be requested per server: SSH_AUTH_SOCK, which is a live credential agent; NODE_OPTIONS, which takes --require and therefore arbitrary code; and the import paths PYTHONPATH / NODE_PATH / VIRTUAL_ENV, which change what a program loads. A server that genuinely needs one takes it explicitly:

[[mcp.servers]]
name = "tooling"
transport = "stdio"
command = "my-tooling-server"
env = { PYTHONPATH = "${PYTHONPATH}" }

A server that genuinely needs a secret asks for it by name, and ${VAR} still reads meka’s environment at connect time:

[[mcp.servers]]
name = "github"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
env = { GITHUB_PERSONAL_ACCESS_TOKEN = "${GITHUB_TOKEN}" }

Examples

Exa: web search, which meka has no built-in tool for. The free tier works without an API key; paste a key into the headers table for the paid tier:

# Free tier, no key required
meka mcp add exa https://mcp.exa.ai/mcp
# Paid tier, expands from EXA_API_KEY at connect time
meka mcp add exa https://mcp.exa.ai/mcp --header "x-api-key=${EXA_API_KEY}"

Well-annotated server: no config needed. Every tool is classified by its own readOnlyHint (read tools Read, write tools Write):

[[mcp.servers]]
name = "notion"
transport = "http"
url = "https://mcp.notion.com/mcp"

User-declared trust on an unannotated server (all tools accessible in Read):

[[mcp.servers]]
name       = "internal"
transport  = "http"
url        = "https://mcp.internal/…"
permission = "read"

Overriding a mis-annotated or distrusted tool (one specific tool requires unrestricted):

[[mcp.servers]]
name      = "notion"
transport = "http"
url       = "https://mcp.notion.com/mcp"

[mcp.servers.tool_permissions]
"notion-do-something-scary" = "unrestricted"

Subset of a server’s tools (only query registers, all others are ignored):

[[mcp.servers]]
name          = "pg"
transport     = "stdio"
command       = "npx"
args          = ["-y", "@modelcontextprotocol/server-postgres"]
allowed_tools = ["query"]

Block-list with a narrow exception (all fs tools are Read-accessible except the two destructive ones, which are never registered):

[[mcp.servers]]
name           = "filesystem"
transport      = "stdio"
command        = "npx"
args           = ["-y", "@modelcontextprotocol/server-filesystem"]
permission     = "read"
disabled_tools = ["delete_file", "move_file"]

MCP tools are registered with namespaced names in the format servername__toolname to prevent collisions with built-in tools or between servers.

Tool and resource descriptions returned from MCP servers are truncated at 2048 characters to keep the rendered catalog bounded.

Environment variable substitution

Every string field listed above (command, args, env values, url, headers values, headers_helper) supports ${VAR} and ${VAR:-default} expansion from the process environment. A missing variable with no default is logged at startup. In command or headers_helper it is left literal, naming a program that will not be found; in args, env, url or headers, which reach the other side, it fails closed instead: the server is marked failed and never connected, so a literal Bearer ${TOKEN} or ?key=${KEY} is not sent to anyone. A reference the grammar does not accept (an unclosed ${, an empty name, a default that opens another reference) is treated the same way. A $NAME without braces is not a reference and is passed through, for a child shell to expand. Use this to avoid committing secrets:

[[mcp.servers]]
name = "github"
transport = "http"
url = "https://mcp.github.com"
headers = { X-Api-Key = "${GITHUB_MCP_TOKEN}" }

env, args and headers may contain a secret, but they are not one: env sets a subprocess’s whole environment, args carries connection strings, and headers carries X-Tenant-Id as readily as X-Api-Key. meka cannot tell which is which, so they stay in config.toml and ${VAR} is how you keep a value out of it.

A bearer token and an OAuth client secret are unambiguously secrets, so they are not config at all. They live in the store and are set with meka mcp add --auth-token-stdin / --client-secret-stdin, or afterwards with meka mcp login. See Credentials.

A stored credential is bound to the url it was stored for, ${VAR} expanded, and presented there alone: an OAuth login’s tokens to that exact URL, the resource they were issued for, and a bearer or a client secret anywhere on its scheme, host and port. Point url at another host and the server stops presenting what it holds, and fails to connect saying so, until it is logged in or given its secret again; meka mcp get lists what it holds for another endpoint. A ${VAR} in url that the environment leaves unset binds nothing: storing a secret for the server is refused where it is unset.

[mcp.servers.auth]

OAuth authentication for HTTP MCP servers. Set type to choose the authentication method. This is mutually exclusive with a stored bearer token. Every token request names the server’s own url as its resource (RFC 8707), as the MCP authorization spec requires, so no key states it.

The client secret is not a field here. It is a secret, so it lives in the store: set it with meka mcp add --client-secret-stdin or meka mcp login <name> --client-secret-stdin. See Credentials.

FieldRequiredDescription
typeYesAuth method: "client_credentials", "client_credentials_jwt", or "oauth"
client_idVariesOAuth client id (required for client_credentials/jwt, optional for oauth with dynamic registration)
scopesNoOAuth scopes to request
signing_key_pathJWT onlyPath to PEM private key file
signing_algorithmNoJWT signing algorithm: RS256 (default), RS384, RS512, ES256, ES384
redirect_portNoLocal port for OAuth authorization code callback. When omitted, meka binds to a random ephemeral port (recommended). oauth only.

Examples

Stdio server

[[mcp.servers]]
name = "postgres"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"]
permission = "unrestricted"

HTTP server

[[mcp.servers]]
name = "web-tools"
transport = "http"
url = "http://localhost:8080/mcp"
permission = "read"

HTTP server with authentication

The bearer token is not in the file. Store it once with meka mcp add api https://api.example.com/mcp --auth-token-stdin, or meka mcp login api --auth-token-stdin for a server that already exists.

[[mcp.servers]]
name = "api"
transport = "http"
url = "https://api.example.com/mcp"
permission = "unrestricted"

[mcp.servers.headers]
X-Custom-Header = "value"

Stdio server with environment variables

[[mcp.servers]]
name = "github"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
permission = "read"

[mcp.servers.env]
GITHUB_TOKEN = "ghp_..."

Multiple servers

[[mcp.servers]]
name = "filesystem"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
permission = "read"

[[mcp.servers]]
name = "github"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
permission = "unrestricted"

HTTP server with OAuth client credentials

[[mcp.servers]]
name = "api"
transport = "http"
url = "https://api.example.com/mcp"
permission = "unrestricted"

[mcp.servers.auth]
type = "client_credentials"
client_id = "my-client-id"
scopes = ["read", "write"]

client_credentials needs a client secret, which is stored rather than written here: pass --client-secret-stdin to meka mcp add, or meka mcp login api --client-secret-stdin afterwards.

HTTP server with JWT client credentials

[[mcp.servers]]
name = "api"
transport = "http"
url = "https://api.example.com/mcp"

[mcp.servers.auth]
type = "client_credentials_jwt"
client_id = "my-client-id"
signing_key_path = "/path/to/private-key.pem"
signing_algorithm = "RS256"
scopes = ["admin"]

HTTP server with OAuth authorization code flow

On first connection, meka opens a browser for authorization and stores the token for future use.

[[mcp.servers]]
name = "github-mcp"
transport = "http"
url = "https://mcp.example.com"

[mcp.servers.auth]
type = "oauth"
client_id = "my-app-id"
scopes = ["repo", "user"]
redirect_port = 8400

If client_id is omitted, meka attempts dynamic client registration with the server.

[permissions]

Controls which permission levels are reachable at runtime and which level the session starts at. See the Permissions page for what each level does.

FieldRequiredDescription
defaultNoLevel the session starts at. One of "none", "read", "workspace", "unrestricted". Default "read". Overridden by --permission and MEKA_PERMISSION.
enabledNoList of levels that can be reached at runtime via /permission and Shift+Tab. Default ["none", "read", "workspace", "unrestricted"]. Disabled levels are skipped during Shift+Tab cycling and refused by /permission with an error.
approvalsNoWhether a new session starts with approvals on: a tool call needing more than the session’s level is put to you rather than refused. Default false. A session records its own switch afterwards, moved by /approvals, PATCH /v1/sessions/{id} or the ACP approvals config option.
confine_readsNoWhether a root session’s reads below unrestricted are confined to its workspace, its additional readable directories and the directories a command needs to run, the way writes are confined to the workspace at workspace. Default false. Instance-wide, read at startup: every root session this process runs is bound by it, and unrestricted is the per-session escape. A sub-agent’s reads are confined to its scope whatever this says. See The read boundary.
networkNoWhat a confined command and web_fetch may reach: "host", the machine’s network; "remote", other machines but not this one; or "none". Default "host". web_fetch, which runs in the meka process where no sandbox reaches, holds every value itself: under "none" it is withheld from every session and listed by meka tool list as disabled; under "remote" it refuses this machine’s own addresses (loopback, unspecified, and any address an interface here holds) at the URL, at every redirect and at the resolver, while a machine on the LAN stays reachable; with a proxy configured the proxy resolves the target and only the proxy’s own name is judged, as under [web].refuse_private_destinations. A command holds what its backend can. Under "none", Bubblewrap unshares the network namespace, Landlock refuses every TCP bind and connect and, from ABI v10 (kernel 7.2), every UDP bind and send, sandbox-exec withholds its outbound rule, and the Windows AppContainer grants no network capability. A value a backend cannot hold as such runs as the next narrower one it can, and startup says so once with the value to set that silences the warning: Bubblewrap, Landlock and sandbox-exec hold "remote" as "none", and the AppContainer on schema 1.0 holds "host" as "remote". The Windows restricted token has no say over a command’s network, so a command under it reaches the host at every setting, which startup says once; web_fetch holds the setting there too. An MCP server is a separate process outside the sandbox and keeps its own network: a session that must not reach the network is also given no server that can, through [[mcp.servers]] or a sub-agent’s allow_servers. unrestricted runs the shell unsandboxed, so the setting holds web_fetch alone there, and the scope lines say Network: none (web_fetch). A sub-agent holds its parent’s setting unless its spawn narrows it with network, and the scope lines name what the shell and web_fetch each reach when either is narrowed. A boundary like confine_reads, reaching the in-process tool and the sandboxed shell alike, which is why it lives here and not under [shell].

A level meka does not have is refused at parse, with the line. An enabled list that names nothing falls back to read alone, with a warning, rather than to the default set, so an empty list cannot widen authority. If default is not in enabled, meka logs a warning and falls back to read if it’s enabled, otherwise the lowest enabled level (in none → read → workspace → unrestricted order). Same behavior if --permission or MEKA_PERMISSION selects a disabled level: meka warns and starts at the configured default rather than refusing to launch.

[permissions]
default = "read"
enabled = ["none", "read", "workspace", "unrestricted"]
approvals = true     # ask me about anything above the level instead of refusing it
confine_reads = true # reads stay within the roots, as writes do at workspace
network = "none"     # cut off the network; "remote" keeps other machines reachable

[shell]

Settings for shell command execution.

shell.sandbox

Whether to enable read-only filesystem sandboxing for shell commands at read. When enabled (default), shell commands can be executed at read and workspace but with the filesystem write-protected outside the workspace roots. When disabled, shell commands require unrestricted.

Default: true

[shell]
sandbox = false  # disable the sandboxed shell at read

The sandbox uses Bubblewrap or Landlock on Linux, sandbox-exec on macOS, and an AppContainer or a restricted token on Windows, chosen by shell.sandbox_backend where the platform has a choice. On platforms where no backend is usable, shell commands always require unrestricted regardless of this setting.

shell.sandbox_backend

The mechanism a confined command runs under. Each platform takes its own values, and a value from another platform is refused where it would be used: shell_execute at read fails and startup warns, naming the values this platform takes. A config.toml shared across machines leaves the key unset or sets it per machine through MEKA_SANDBOX_BACKEND. macOS has one backend, sandbox-exec, and takes no value.

Linux takes "bubblewrap", "bubblewrap-landlock" and "landlock":

  • Bubblewrap ("bubblewrap") wraps the command in bwrap with read-only bind of /, tmpfs masks over /run / /tmp / /var/tmp / $XDG_RUNTIME_DIR, and --unshare-user --unshare-pid --unshare-uts --unshare-ipc. The tmpfs masks hide the dbus session bus and the systemd-user socket, so state-changing IPC calls like systemctl --user start and dbus-send fail. Network is intentionally not unshared so curl http://x | pdftotext still works. On a kernel with Landlock ABI v6 (6.12) or newer the command also runs inside meka’s Landlock ruleset, enacted by meka confine after the mounts, which closes the abstract namespace, signals and device ioctls, and from kernel 7.1 the sockets the masks do not cover. Requires the bubblewrap package and a kernel with user-namespace creation enabled.
  • Bubblewrap with Landlock ("bubblewrap-landlock") is Bubblewrap with the Landlock layer inside required rather than added where the kernel allows: on a kernel below 6.12 the backend is unusable and shell_execute at read fails, so a pinned value guarantees the layer is applied. What the layer closes still follows the kernel: the abstract namespace, signals and device ioctls from 6.12, sockets on disk outside the masks from 7.1. Auto-detection never picks it; pin it on a host where that guarantee matters more than a working shell.
  • Landlock ("landlock") uses the Landlock LSM to block filesystem writes, and requires ABI v9 (kernel 7.1+): the first ABI that can refuse connect() to Unix sockets on disk, which is the dbus / systemd-user route out of the sandbox. On an older kernel meka reports the backend unusable rather than leave that door open; install Bubblewrap there. Closing it also costs socket-based clients like docker and psql at read. meka’s own directories are hidden by per-sibling read grants, and each command gets a private temporary directory named by TMPDIR. No ABI mediates file metadata, so chmod, chown, touch and setxattr still succeed at read; that is what the startup warning names.

Windows takes "appcontainer" and "restricted-token":

  • AppContainer ("appcontainer") runs the command inside a process security environment the OS enforces, on a Windows 11 build that has the API: 26H2, where meka measured it, and 24H2 and 25H2 from the August 2026 update, where Microsoft lists it. meka’s config directory and store are denied, the read boundary holds for the shell as it does for the file tools, network = "none" cuts a command off, and nothing is written to your directories’ ACLs. See Shell for what it covers and its limits.
  • Restricted token ("restricted-token") runs the command under a token derived from meka’s own: Low integrity at read, write-restricted to the workspace roots at workspace. It confines writes only: a command can read meka’s store, confine_reads holds for the file tools alone, and network = "none" for web_fetch alone. That is what the startup warning names when it is the automatic choice.

When omitted, meka probes the platform’s stronger backend once at startup, Bubblewrap on Linux and AppContainer on Windows, and auto-picks it when usable; AppContainer counts as usable at schema 1.1, and a build whose API speaks 1.0 is named in the warning, since pinning "appcontainer" takes it there with network = "host" held as "remote". Otherwise it auto-picks the fallback, Landlock or the restricted token, and emits a one-shot warning naming what the fallback gives up. Set the field explicitly, to the fallback included, to suppress that warning. No command writes this field; leave it unset to keep auto-detection.

If the configured backend can’t be used at runtime (bwrap not installed, user namespaces denied, the AppContainer API missing on this build, a value from another platform), shell_execute at read hard-errors with a message naming the configured backend, the specific failure reason and the key to fix. read is not blocked for other tools; only shell_execute requires a usable sandbox.

Overridable for one run with meka --sandbox-backend <value>, and for a whole environment with MEKA_SANDBOX_BACKEND. Precedence is flag, then environment, then this field.

Default: unset (auto-detect).

[shell]
sandbox = true
sandbox_backend = "bubblewrap"  # Linux; "appcontainer" or "restricted-token" on Windows

shell.additional_readable_directories

Directories every confined command may read beyond the system directories, wherever reads are confined: with permissions.confine_reads on, and for every sub-agent, whose reads its scope confines. Additional beyond the system directories, as the flag --additional-readable-directory is per run. The system directories are where the operating system keeps the programs, libraries and configuration a command needs to run at all, and the list is deliberately system-level: on Linux /bin, /sbin, /usr, /lib, /lib32, /lib64, /etc, /proc, /dev, /nix/store and /run/current-system/sw; on macOS /bin, /sbin, /usr, /etc, /System, /Library/Apple, /Library/Preferences, /Library/Filesystems/NetFSPlugins, /var/db, /opt/homebrew, /Library/Developer and /dev, plus the active developer directory xcode-select records, since the developer commands in /usr/bin are stubs that run the real binaries from there. Each is taken only where it exists. The skill directories [skills].additional_directories names are readable too. Add here what else this machine keeps system software under: /opt, /snap, a toolchain outside the home directory. An entry is warned about and dropped when it does not resolve, when it is relative, or when it is a directory the sandbox masks (/tmp, /run, the runtime directory, the filesystem root, meka’s own directories), since Bubblewrap binds the readable directories after its masks and /run named here would hand the session bus back to a confined command. On Windows the system directories are the Windows directory, Program Files, Program Files (x86) and ProgramData, located by the environment; under the restricted token the shell’s reads are not confined, so the list reaches the file tools alone there.

Default: []

[shell]
additional_readable_directories = ["/opt", "/snap"]

[tools]: built-in tool filters

The three knobs [[mcp.servers]] exposes for MCP tools also apply to meka’s built-in tools (file_read, file_write, shell_execute, etc.) via a top-level [tools] table. MCP per-server filtering is separate from this and keeps its own namespaces; this block only affects the built-ins.

KeyPurpose
allowed_toolsOptional allow-list of built-in tool names. When set and non-empty, only these built-ins register, with two exceptions: the seven MCP meta-tools register regardless, because they are how the agent reaches a configured server’s resources and prompts at all, and so does context_replace, compaction’s own plumbing, which no list reaches and which is refused outside a checkpoint. Naming a meta-tool here is inert and warns at startup; use disabled_tools to remove one. Use meka tool list to see the canonical names.
disabled_toolsBlock-list of built-in tool names. Applied after allowed_tools; a tool here is never registered even if it also appears in the allow-list.
tool_permissionsPer-tool required-permission override keyed by built-in name. Beats the hardcoded required level from the tool’s impl. Levels: none, read, workspace, unrestricted; any other value is refused at startup, naming the line.

Stale entries (a name that matches no built-in) emit a warn! at startup. meka still starts; the warning just flags a likely typo or a tool the binary renamed.

Restrict a session to read-only inspection:

[tools]
allowed_tools = ["file_read", "file_find", "file_search", "web_fetch"]

Force shell_execute to need unrestricted, so a session below that with approvals on prompts for every shell call:

[tools.tool_permissions]
shell_execute = "unrestricted"

Disable web access entirely in a locked-down environment:

[tools]
disabled_tools = ["web_fetch"]

Sub-agents spawned via agent_spawn inherit the same filter; a disabled built-in is disabled everywhere. To take something away from sub-agents only, use [subagents]. Run meka tool list to see every built-in’s effective required permission, whether a [tools.tool_permissions] override is in effect, and whether the current config enables it.

[subagents]

How deep sub-agents may nest, what one may never hold, and the one choice its parent may make for it. Where [tools] restricts everyone, this block applies only to sub-agents.

KeyTypeDefaultDescription
max_depthint3How deep sub-agents may nest; 1 lets only the root spawn, 0 withholds agent_spawn
disabled_serverslist[]MCP servers a sub-agent cannot see at all
disabled_toolslist[]Individual tool names a sub-agent cannot see
agent_chosen_profileboolfalseLet the spawning agent choose the profile a sub-agent runs on
[subagents]
max_depth = 2
disabled_servers = ["mekabridge"]
disabled_tools = ["mcp__notion__create_page"]

max_depth bounds nesting through agent_spawn. The root agent spawns at depth 1, its sub-agents at depth 2, and so on; each level below the cap is granted its own agent_spawn. 1 lets the root spawn and nothing below it; 0 withholds agent_spawn from everyone, and the four lifecycle tools with it. An agent can narrow a subtree with the tool’s max_depth parameter, never widen it, and a built-in absolute cap bounds real nesting whatever either says.

disabled_servers is the one that matters. Naming a server removes everything it offers from every sub-agent: its tools, its resources, and its prompts. Reach for it when a server exists to talk to you or to act on your behalf. The motivating case is a server that can message the user: without this, a sub-agent three levels down can send a message the user has no way to distinguish from the one they are actually talking to.

disabled_tools takes names as they appear in the tool list, so built-ins (file_write) and namespaced MCP tools (mcp__notion__create_page) share one namespace. For a whole server, prefer disabled_servers: it covers the resource and prompt surfaces that a tool-name list cannot reach.

An entry matching nothing emits a warn! at startup, the same way [tools] does. A typo here denies nothing while reading as a restriction, which is worse than writing no config at all.

These are floors. An orchestrator can restrict a particular sub-agent further with agent_spawn’s allow_servers, allow_tools, deny_servers and deny_tools parameters, and each level of nesting inherits everything above it, but nothing can grant back what this block took away: a call-site allow-list only narrows, and a tool named here is withheld whatever an allow-list says.

agent_chosen_profile = true lets an orchestrator run a sub-agent on a profile other than its own: agent_spawn gains a profile parameter whose choices are every configured profile, so a mid-tier model can dispatch hard tasks to an expensive one and trivial ones to a cheap one. It is off by default because a sub-agent then bills whatever account the chosen profile names, and that is a decision to make once, in the config, rather than one the model makes on every spawn. The agent only sees profile names, so say in your standing instructions what each profile is for. A sub-agent spawned with a profile keeps it on every follow-up; one spawned without follows its parent’s profile.

Why memory and instructions are not configured here

Two things a sub-agent might inherit are deliberately absent: the memory store and the instructions file. Both are granted per call by agent_spawn and default to nothing.

The distinction is what config can actually enforce. A capability can be withheld: a tool the registry never registered cannot be reached, however the parent phrases the task. Context cannot. An agent holding the instructions has them verbatim in its own system prompt, and one with memory_read can read any memory, so either can be copied into a sub-agent’s prompt whatever config says. A [subagents].memory = "none" key would look like a boundary while stopping only the sub-agent’s own browsing, not the content reaching it, and a control that reads as a guarantee but isn’t one is worse than none.

The other half of the argument is that the config guardrail existed for a failure mode that no longer applies. It was there because the parent might forget, which only matters for things that are on by default. Both of these now default to off, so forgetting produces a clean sub-agent.

[instructions]

Where the standing instructions are read from beyond the conventional path. See Reading a project’s AGENTS.md in the Instructions guide.

KeyTypeDefaultDescription
filesarray[]Files, or directories of files, read into the standing instructions when a session opens
[instructions]
files = ["AGENTS.md", "~/notes/meka-site.md"]

A relative entry resolves against the session’s working directory, which is how AGENTS.md names whichever project the session opens in; an absolute one is read as given, and a leading ~ is expanded. The text follows the standing instructions under the same heading. It is read when the session opens rather than at startup, so a meka -c or a new serve session sees an edit. An entry that is not there is skipped without a word; one that cannot be read is skipped with a warning.

Empty by default, and deliberately so: a relative entry lets whatever sits in a session’s directory speak with the operator’s authority, and under meka serve the client chooses the directory. Name one only where every directory a session may open in is yours to trust.

[skills]

Controls the skill store. See the Skills guide.

KeyTypeDefaultDescription
enabledbooltrueRegister skill_read / skill_search and render the skills index
agent_managedboolfalseAdditionally register skill_write, skill_edit and skill_delete
additional_directoriesarray[]Directories to scan beyond meka’s own store, read-only
[skills]
enabled = false

Setting enabled = false keeps every skill tool’s schema out of every request and renders no skills section. Files already in ~/.config/meka/skills/ are left untouched.

agent_managed = true lets the agent author its own skills. It is off by default because you normally curate that store yourself; it exists for a long-running agent that dispatches sub-agents, where a skill is the only artifact that both survives the session and can be handed to a sub-agent as its task. Sub-agents never receive the authoring tools whatever this is set to. See Letting the agent manage skills.

additional_directories adds directories to the scan beyond meka’s own store. They are strictly read-only: meka never creates them and never writes into them, so an entry that does not exist is simply skipped and leaves nothing behind. A leading ~ is expanded. Where reads are confined, these directories stay readable, as meka’s own skills store does, so a skill’s bundled files can be read; see the read boundary.

[skills]
additional_directories = ["~/.agents/skills"]

~/.agents/skills is the cross-client convention, so pointing at it makes skills installed by other Agent Skills clients visible here. It is not a default: reading a directory outside meka’s own namespace is your call. meka’s own store is searched first and wins a name collision. There is no automatic project-level scan, for the same reason meka reads nothing from the working directory that config does not name, the files under [instructions] included; name the path here if you want a project’s skills read. See Reading skills from other directories.

An entry that repeats an earlier one, or that names meka’s own skills directory, is dropped with a warning: it would otherwise be scanned twice and every skill in it reported as shadowed by itself. An empty string is dropped too, since it would expand to your home directory.

[memory]

Controls the agent’s durable note store. See the Memory guide.

KeyTypeDefaultDescription
enabledbooltrueRegister the memory_* tools and render the memory index
inline_max_bytesinteger32768Bytes the bodies of the in-force inline memories may total; a write past it is refused
[memory]
enabled = false

Setting enabled = false keeps the five memory_* tool schemas out of every request and renders no memory section, which is worth doing for lean sessions that will never use it. Memories already stored are left untouched, and meka memory still reaches them.

inline_max_bytes bounds what the agent carries whole in its context. The inline band is rendered into the opening message once per session and again after each compaction, so a larger budget costs prefix tokens the cache holds rather than tokens on every turn. A write that would take the inline tier past it is refused at every door, the operator’s included, and a store already past it (the key lowered, or rows written past meka’s doors) shows the bodies that fit and lists the rest by description until they are trimmed; see The inline budget. Zero is refused at startup.

There is deliberately no environment variable and no CLI flag here: whether an agent keeps memories is a property of the installation, not something to vary per run.

[schedule]

Controls the wakeups the agent schedules for itself. See the Scheduling guide.

KeyTypeDefaultDescription
enabledbooltrueRegister the schedule_* tools and run the scheduler
poll_intervalduration"10s"How often due jobs are checked; "0s" is refused at startup
missed_graceduration"24h"How late a one-shot job may be and still fire after downtime
gate_timeoutduration"30s"Wall-clock budget for a gate probe; "0s" is refused at startup
claim_leaseduration"1h"How long a host’s claim on a due occurrence is good for
max_jobsint50Per-session ceiling, refused at schedule_create; 0 is refused at startup
max_consecutive_firesint5Per-session ceiling on turns spent in one sweep
[schedule]
enabled = true
poll_interval = "10s"
missed_grace = "24h"
gate_timeout = "30s"
claim_lease = "1h"
max_jobs = 50
max_consecutive_fires = 5

poll_interval is the real resolution floor: a job whose interval is shorter than the tick fires once per tick, not once per interval.

missed_grace applies only to one-shot jobs. Recurring jobs need no equivalent, because their occurrences are one period apart, so the most recent missed one is always less than a period old; the scheduler coalesces the rest into a single catch-up fire.

claim_lease is how long a crashed host’s occurrence stays unavailable before another host takes it. A due job is leased rather than consumed, so the row survives until the turn is delivered and a host that dies mid-delivery costs a retry rather than the occurrence. Raise it only if a gate probe plus a turn could plausibly exceed an hour; lowering it below that risks a second host taking an occurrence the first is still running, which the session lock catches at the cost of a deferral and a re-run gate probe. A host refuses to start on a value at or under gate_timeout, since a lease that cannot outlast the host’s own probe is never right; that check does not cover the turn after the probe, which is unbounded, so leave headroom on top of it.

max_consecutive_fires interleaves sessions: without it, one session’s whole backlog runs to completion before another session’s single due job is reached. Jobs past the budget keep their occurrence, run no gate, and are taken by the next sweep most-overdue first. It bounds a batch rather than a rate: sweeps do not overlap and the next starts as soon as the last ends, so a backlog still produces one turn per job, just in interleaved groups. 0 is refused, since it would hold every job over forever; use enabled = false to turn scheduling off.

Setting enabled = false keeps the three schedule_* tool schemas out of every request and leaves existing jobs on disk without firing.

As with [skills] and [memory], there is no environment variable and no CLI flag: whether an agent may schedule its own turns is a property of the installation.

[background]

Controls tool calls the agent starts and does not wait for. See the Background tasks guide.

KeyTypeDefaultDescription
enabledboolfalseOffer the background parameter and register the task_* tools
max_tasksint10Concurrent tasks per session, refused at dispatch; 0 is refused at startup while enabled
[background]
enabled = true
max_tasks = 10

Alone among the capability blocks, this one is off by default. [schedule], [skills], and [memory] add capability without changing when a turn ends; this changes the contract of the primary interaction into “you asked, it answered, and something else may interrupt you later”. That is right for an unattended assistant and wrong for someone using the screen as a command line. A scheduled job also takes an explicit act to create, whereas background is reachable from any tool call, so an agent will reach for it unprompted.

Setting enabled = false keeps the background property out of every tool schema and the two task_* tools out of every request, rather than advertising a parameter that would only ever be refused.

Outcome delivery shares [schedule].poll_interval, so that key sets how long a finished task waits before it is reported, whether or not scheduling itself is enabled.

Config-only, like the blocks above: no environment variable, no CLI flag.

[session]

Settings for session history retention and context window management.

session.retention

Delete sessions not updated for longer than this, at startup and then hourly for as long as meka serve, meka acp or the screen stays up. A duration string like "30d" or "12h". Uses updated_at, so an actively-resumed session is preserved even if created long ago. Deletions are reported at warn level.

The screen’s recall entries, the lines Up and Ctrl+R bring back, are swept with it: an entry older than this window, or than turn_retention when that is the shorter, is deleted, since what a person typed must not outlive the conversation it went into.

Three kinds of session are spared whatever their timestamp says. A session another meka process has open is skipped, and the sweep reports how many: updated_at moves when the conversation changes, not when a session is opened, so an interactive session left open past the window looks expired while somebody is in front of it. A pinned session is never expired, nor is any parent of one: a pin is you saying keep this. And a session with a scheduled job still ahead of it is never expired, nor is any parent of one: a gated watcher that evaluates every tick and rarely fires looks untouched for exactly as long as it is working, and deleting it would take the schedule with it.

Default: unset, meaning nothing is deleted. "0s" is refused at startup, since it would delete everything on every launch. Conversation history isn’t reproducible, so meka keeps it until told otherwise. Use meka session remove --older-than-days <DAYS> to prune manually instead.

[session]
retention = "30d"

session.turn_retention

Delete the turns of every session that ended longer ago than this, once a compaction has summarized them. A duration string like "90d" or "36h". The sweep runs at startup, at the start of each turn for that turn’s session, and hourly for as long as meka serve, meka acp or the screen stays up.

Only turns before a session’s latest compaction can go, since the summary is what stands in for them: the turns the model still works from are never deleted out from under it, and a turn a compaction cut into keeps only the rounds it kept. When one of those is older than the window, the next turn compacts first, keeping no rounds verbatim and quoting none, and the sweep takes it afterward. Needs auto_compact = true for that reason. A turn goes whole: its messages, its record, and with it its place in the session’s turn_count and usage, its words in the search index and the images only it carried. Inbox items and background outcomes go once they have been delivered or withdrawn for longer than the window. Scratchpad entries and memories are the agent’s own and stay.

The model is told the window in its system prompt, and every turn’s context block names the date nothing precedes, so it knows how far back conversation_search reaches and to keep in memory what must outlive the window. See What the agent sees.

Default: unset, meaning every turn is kept for the life of its session. "0s" is refused at startup, since it would compact and delete the conversation on every turn. Deletions are reported at warn level.

[session]
turn_retention = "90d"

session.auto_compact

Automatically compact the conversation once it is past context_ceiling_percent of the context window, between turns or between two tool rounds of one turn. Compaction summarizes older messages and preserves recent ones, the open checklist, and scratchpad entries. Off changes nothing else: a whole scratchpad_read still stops at the ceiling, and a request past the window fails the turn.

Default: true

[session]
auto_compact = false

session.context_ceiling_percent

The share of the context window meka lets the conversation fill on its own. Two things happen at the line: with auto_compact on, the conversation is compacted once past it; and a whole scratchpad_read that would carry the context past it is cut there and says where to continue, whether or not compaction is on. Refused outside 1 through 100.

What is left above the line has to hold the reply and one round’s growth past it, so keep at least your output budget plus a round free: the API refuses a request whose input and output cap together exceed the window. The default leaves 150k tokens on a 1M window against a Claude reply budget of 128000; on a small window it needs lowering, or max_output_tokens does.

Default: 85

[session]
context_ceiling_percent = 70

session.compact_checkpoint

Run a checkpoint turn before each compaction, in which the agent saves anything that must outlive the window and writes the replacement summary itself. See Compacting a session.

Costs one extra model call per compaction. Turning it off falls back to a standalone summarizer that has no tools and none of the standing instructions, so it cannot save to memory and cannot apply any judgment about what this particular deployment is for.

Note that this applies to automatic compactions too, so an unattended checkpoint can write memory with nobody watching.

Default: true

[session]
compact_checkpoint = false

session.context_window

Override the model’s context window size (in tokens). Used for the context ceiling. A per-profile [profiles.<name>].context_window takes precedence over this.

When neither is set, meka assumes 1000000. It does not infer the window from the model name, query the provider’s models API, or cache anything: the window is a local budgeting number that is never sent on the wire, so a wrong value can’t fail a request, and the user is the one who knows the truth.

1M suits the current flagship models and overshoots the smaller and older ones. Overshooting is survivable rather than free: planned compaction never fires, so those sessions compact only after the provider rejects an over-long request, paying a wasted round trip each time. Set the real window on any profile whose model is smaller.

[session]
context_window = 200000

[thinking]

Presentation and budget settings for extended thinking (anthropic-messages and claude-subscription backends). Whether thinking is on, and which wire encoding it uses, is the per-profile thinking key, not a setting here.

While the model is thinking, the screen draws a live • Thinking row so a long pause reads as work rather than as a hang. On claude-subscription it carries the server’s own running estimate (• Thinking · 150 tokens), redrawn in place as the count climbs; anthropic-messages does not report one, so the row stays bare. The count is coarse: a progress signal, not an accounting figure.

When the block ends the line stays on screen as a record that the phase happened; if the model returned readable reasoning, that text replaces the line instead.

thinking.budget

Maximum number of tokens the model can use for thinking. Read only under thinking = "budgeted"; the adaptive encoding lets the model set its own budget and sends no cap. A per-profile [profiles.<name>].thinking_budget takes precedence over this.

Default: 16000

[web]

Settings for the HTTP client web_fetch uses. All keys are optional; unset fields use the defaults shown below.

KeyTypeDefaultPurpose
user_agentstringReal Chrome UASome sites block non-browser UAs. Override if you need a specific identifier.
request_timeoutduration"30s"Total request budget (connect + TLS + read). "0s" is refused at startup.
connect_timeoutdurationunsetSeparate cap on TCP + TLS handshake. Fail fast on unreachable hosts without shortening the whole request budget. "0s" is refused at startup.
read_timeoutdurationunsetPer-chunk idle timeout. Catches bodies that stall mid-stream. "0s" is refused at startup.
max_redirectsint10Cap on 3xx hops. 0 means no redirects are followed: a 3xx is returned as the response.
proxystringunset (honors HTTP_PROXY / HTTPS_PROXY / ALL_PROXY env)Proxy URL. Schemes: http://, https://, socks5://, socks5h://, socks4://. The literal string "none" explicitly disables env-var auto-detection.
ca_cert_filepathunsetExtra PEM bundle to trust on top of the system store. Useful for corporate MITM proxies or self-signed internal services. Accepts single-cert and multi-cert files.
https_onlyboolfalseRefuse plain http:// URLs.
min_tls_versionstringunset (reqwest default)Minimum TLS version. Accepts "1.0", "1.1", "1.2", "1.3". Unknown values log a warning and fall through. Note: the bundled rustls backend supports only TLS 1.2 and 1.3; "1.0" / "1.1" will surface a build error.
danger_accept_invalid_certsboolfalseDANGEROUS. Disable TLS certificate validation entirely. Emits a warn! on every startup when enabled. Only use against trusted local dev servers.
danger_accept_invalid_hostnamesboolfalseDANGEROUS. Accept certificates whose hostname doesn’t match. Emits a warn! on every startup when enabled. Only use against trusted local dev servers.
refuse_private_destinationsboolfalseRefuse a fetch whose destination is a loopback, private, link-local, carrier-grade NAT or unspecified address, judged on every connection: the URL as given, every redirect hop, and every address a host name resolves to, so a name that answers differently twice cannot slip past. Off by default, since a local service is often exactly what a fetch is for. With proxy set, the proxy resolves the target and the guard sees only the proxy’s own name, which it judges too: a proxy on this machine is named by its address, 127.0.0.1, not localhost.

Example: corporate proxy with a private CA

[web]
proxy = "http://corp-proxy.internal:3128"
ca_cert_file = "/etc/ssl/corp-root-ca.pem"
min_tls_version = "1.2"
request_timeout = "60s"

Example: local testing against self-signed certs

[web]
# Route everything through a local SOCKS proxy you control.
proxy = "socks5h://127.0.0.1:1080"
# Accept self-signed certs on dev.local, KEEP THIS OFF IN PROD.
danger_accept_invalid_certs = true

Example: fail-fast timeouts

[web]
request_timeout = "5s"
connect_timeout = "2s"
max_redirects = 0

[display]

Settings for the interactive screen: the two rows it composes, whether finished steps fold, the session id lines, what Enter does while a turn runs, the mouse, and stream, which also decides how a one-shot run asks the provider.

display.status_row

Which facts the status row under the composer shows, each in its place and, within it, in the order listed. On the left, after the permission level, which always opens the row, the profile and the model say what the next prompt goes under, and the path and the project where the session works; the context gauge and the running work stand against the right edge.

  • path: the session’s working directory, shortened with ~
  • project: the project the session is on, by name, while it is on one
  • profile: the profile the session runs on, by name
  • model: the model the profile runs, when the config names one; off by default, since a profile named for its model would show it twice
  • context: the live context-window gauge, e.g. 128.4k / 1.0M (13%) (tokens in context / model window, and the share used), the same figures /status shows on its Context: line; shown from the start at 0 / <window> (0%), omitted only while the window is unknown
  • tasks: 2 background tasks, while any run
  • subagents: 1 sub-agent, while any run

A hint (Copied 3 rows, The question was withdrawn) takes the status row’s right side while it stands, whatever the list says. Where the row is narrow, the level stays whole and the readings give way to it, each shown whole or not at all, from the last, since a reading cut short reads as another number; the facts after the level give way from the last too: the one that no longer fits is cut (a path from its start), or dropped when too little of it would show. A word that is not one of these, or one listed twice, is refused by name.

[display]
status_row = ["profile", "context"]

Default: ["profile", "path", "project", "context", "tasks", "subagents"]

display.turn_end_summary

What the row that closes a turn states, in the order listed:

  • time: when the turn ended, as a time of day today and with the date otherwise
  • duration: how long it ran
  • usage: the tokens it used, with the share of the input that came from the cache

The row reads as one sentence whose first word says how the turn ended:

• Finished at 19:28 after 19m 19s
• Interrupted at 19:30 after 2m 3s
• Finished at 19:28 after 19m 19s with 12.3k tokens in, 1.2k out, 96% cached

A fact with nothing to say is left out. An empty list draws no row. A word that is not one of these, or one listed twice, is refused by name. On a resumed session every recorded turn closes with the same row, read from the store, so a scrollback says when each turn ended. A one-shot run prints none; --format json carries the turn’s usage.

The usage’s in count is the total of all three Anthropic input tiers (live, cache-write, cache-read); the cached share is cache_read / in. The /status slash command surfaces the session’s cumulative numbers in the same vein.

[display]
turn_end_summary = ["time", "duration", "usage"]

Default: ["time", "duration"]

display.fold_steps

Whether the interactive screen folds a run of thinking and tool calls into one row, such as ▸ Thought twice, ran 3 commands, read a file, once the agent writes something after it or its turn ends. Changes to files, memories and skills, and agent_spawn and agent_followup calls, never fold. Ctrl+O opens every run, with every call expanded, until it is pressed again. With false, every run starts open and still folds when you click its row. See Folded steps.

Default: true

[display]
fold_steps = false

display.show_session_id_on_create

Whether the screen shows the session id when a new session is created.

Default: false

display.show_session_id_on_resume

Whether the screen shows the session id when a session is resumed with -c, -r or /resume.

Default: false

display.show_session_id_on_exit

Whether the screen shows the session id when meka exits.

Default: true

A one-shot run prints no session id whatever these say: --format json carries it as session_id, and -v logs it.

[display]
show_session_id_on_create = true
show_session_id_on_resume = true
show_session_id_on_exit = false

display.send_class

What Enter does on the interactive screen while a turn is running: steer posts the message to the session’s inbox, where the agent reads it at its next round; followup holds it and sends it as the next turn once this one ends; interrupt cuts the answer being streamed and hands the agent the message at once. Alt+S cycles the class for one run. See While a turn runs.

Default: steer

[display]
send_class = "followup"

display.mouse

Whether the interactive screen captures the mouse. With it on, the wheel scrolls the transcript and a click expands or collapses a block; the terminal’s own click-and-drag then needs its modifier key (Shift in most terminals, Option in iTerm2) to select text. With it off the mouse is the terminal’s, and the keyboard still scrolls and toggles. See Scrolling and the mouse.

Default: true

[display]
mouse = false

display.wheel_rows

How many rows the transcript, or a panel under the pointer, moves for each wheel event the terminal sends. The terminal already decides how many events one notch of the wheel sends: Ghostty sends three, a trackpad sends one per few pixels, and VS Code’s terminal sends one, so one row per event follows what the terminal meant. Raise it if your terminal sends one event per notch and scrolling feels slow. 0 is refused at startup.

Default: 1

[display]
wheel_rows = 3

display.stream

Whether the provider is asked to stream its reply or to return it whole. On the screen a streamed answer is drawn as it arrives and a whole one lands when the turn ends; a one-shot run prints its answer when the turn ends either way. The --no-stream flag turns streaming off for one run; this key is the standing preference, and it applies to sub-agents as well.

Default: true

[display]
stream = false

[serve]

Configuration for meka serve, the HTTP API server. See the HTTP API usage guide for a full walkthrough.

A ${VAR} in a [serve] value (a token, a webhook URL) is expanded from the process environment by the grammar the MCP fields use, ${VAR:-default} included. A name the environment does not supply and no default covers refuses the server at startup, since a token left reading ${VAR} would be an auth-bypass-shaped configuration; so does a reference the grammar does not accept (an unclosed ${, an empty name, a default that opens another reference), which would otherwise become the token as written.

serve.bind

Address and port the HTTP server listens on.

TypeDefault
string"127.0.0.1:8080"
[serve]
bind = "0.0.0.0:8080"

Security: Binding to 0.0.0.0 exposes the server on all interfaces. In production, keep 127.0.0.1 and front with a TLS-terminating reverse proxy.

serve.cors_allowed_origins

Browser origins allowed to call the API cross-origin, for a web application served from somewhere other than meka itself. Omitted or empty, the server sends no CORS headers at all, and a browser refuses every cross-origin call.

TypeDefault
array of string[] (cross-origin access off)
[serve]
cors_allowed_origins = [
    "https://owner.github.io",
    "http://localhost:5173",
]

An origin is a scheme, a host and a port, and nothing else: https://owner.github.io/mekaweb/ has the origin https://owner.github.io, and a path cannot narrow the grant. Each entry is normalized at startup to what a browser sends in Origin (lowercase host, default port dropped, a trailing root slash tolerated), and a request is granted only when its origin matches an entry exactly: another scheme, port or subdomain is another origin. An entry with a path, query, fragment or credentials, a non-HTTP scheme, null, or a pattern such as https://*.example.com is refused at startup.

["*"], alone, grants any origin. That is safe here because the API authenticates with a bearer header the page sets itself and never with a cookie: a page without the token gets a 401 from any origin, and a page holding it can use it from anywhere regardless. The allowlist guards only what needs no token: the two health probes, the opt-in OpenAPI document and the body of a 401. Name your origins where you can; use * for a LAN deployment reached from several device addresses. Browser clients describes what the grant covers.

serve.max_body_bytes

Maximum request body size in bytes. Requests exceeding this limit are refused with 413 Payload Too Large. 0 is refused at startup; omit the field for the default.

TypeDefault
integer10485760 (10 MiB)

serve.relay_provider_errors

Whether a 502’s payload carries the provider’s own response text, as a provider_response member alongside detail.

On by default. The upstream’s error type is the actionable part of a failed turn, and “consult the server log” is no answer to anyone driving a meka they do not operate. meka acp honors this key too: the same policy decides what a failed turn’s error.data carries.

What it can expose is usually the upstream’s response body, which can name the operator’s account with the provider and its rate-limit posture: a fact about your billing relationship rather than about the caller or the conversation, which is why this is a switch rather than a decision meka makes for you. Not always, though. The member carries the failing call’s error message, and for some failures that is meka’s own sentence about the call rather than anything the provider sent.

It reaches sessions:r, not only sessions:w. Submitting a turn takes the write scope, but the failure also rides the terminal turn.failed event, and GET /v1/sessions/{id}/feed replays that to any reader. Turn this off where read-only tokens go to people who may watch a session but are not entitled to the account behind it.

[serve]
relay_provider_errors = false

detail is unchanged either way, so a client reading only that sees the same sentence and a context overflow keeps its “shorten it before retrying” remedy. Off, the member is simply absent and the text goes to the server log alone.

Bounded to the provider’s own response. A required MCP server that is down still reports only the server names under /errors/mcp-unavailable, because that reason is meka’s own subprocess text and has carried a command line and its filesystem path. This key does not turn that on.

TypeDefault
booleantrue

serve.docs

Whether to serve the OpenAPI document at /v1/openapi.json.

Off by default. It is the only route on the surface that takes no bearer token and describes the deployment rather than reports on it: what it publishes is the shape of every endpoint you expose. That is exactly what you want while building a client against a local meka serve, and exactly what you do not want reachable from anywhere else. Turn it on deliberately.

[serve]
docs = true
TypeDefault
booleanfalse

serve.max_concurrent_turns

Process-wide cap on in-flight turns across all sessions, counting every turn: one a client submits and one the server starts on its own for an inbox item, a scheduled job or a finished background task. When the cap is reached, a new turn submission returns 429 Too Many Requests with a Retry-After header, and an autonomous turn waits for a free slot (the next turn’s end or the next tick) rather than running past the cap. Leave it unset for no limit; 0 is refused at startup, because a cap of zero would 429 every turn rather than mean “unlimited”.

TypeDefault
integerunbounded

serve.stream_replay_events

How many of a session feed’s latest SSE events to retain, across its turns, so a client reconnecting to GET /v1/sessions/{id}/feed with Last-Event-ID can replay what it missed. The events retained also stay within 16 MiB of payload, the oldest leaving first, since one file edit’s event carries the file before and after.

TypeDefault
integer256

Matches the live broadcast channel’s capacity: retaining more than the channel can buffer would let a reconnecting client replay events a connected consumer would have been dropped for missing. Raising it buys a longer reconnect window at the cost of memory for every resident session. 0 switches replay off, so a reconnect receives only what happens from then on and is told its replay is incomplete rather than being handed a silently truncated one.

serve.stream_reattach_grace

How long a held turn keeps running after its request’s client disconnects, waiting for a listener to come back, and how long a parked approval prompt waits after its last attending reader disconnects before it is canceled. Accepts duration strings.

TypeDefault
string (duration)"30s"

Zero subscribers means nobody is listening, and a turn with no audience is spending provider tokens for nothing. That is the right instinct and the wrong deadline: a client whose connection just dropped and one that is never coming back are the same observation until the window expires. Set "0s" to stop a turn at the first round boundary after its request drops, which spends less on abandoned work and makes re-attach useful only for turns that already finished. A turn a request opened with detach: true, and one the server opened, is never stopped for want of a reader; see Holding a turn.

serve.inbox_retry_ceiling

How long a queued inbox item is retried for after a turn on it failed, from when it was enqueued, before it is given up on with inbox.failed. Accepts duration strings.

TypeDefault
string (duration)"1h"

The ceiling bounds the whole wait: a retry never lands past it, whatever the upstream said about when it would be back, so a deployment states its own patience here, and every item shows the deadline it sets as retry_until. Set "0s" to give an item up on its first failure, for a client that owns redelivery itself. A failure no retry would change, a rejected credential or a request the upstream judged wrong, gives the item up at once regardless; one waiting can change, the upstream unavailable, a required MCP server not ready, meka’s own store or disk refusing, is retried within the ceiling. See The inbox.

serve.idle_timeout

How long a session can sit idle (no turns submitted) before the GC evicts it from memory. Accepts duration strings like "24h", "30m", "7d". "0s" turns idle GC off: nothing is ever evicted for being idle.

TypeDefault
string (duration)"24h"

Eviction drops the in-memory runtime but preserves the SQLite row; a later request transparently re-attaches. See delete_on_idle to also remove the row. An open feed keeps its session loaded when its token holds sessions:w, the scope that could load it; a sessions:r reader keeps nothing loaded.

serve.gc_scan_interval

How often the background GC scanner runs. Accepts duration strings; "0s" is refused at startup, since the scanner would then never run.

TypeDefault
string (duration)"5m"

serve.delete_on_idle

When true, idle-evicted sessions also have their SQLite row deleted, unless a pin or a scheduled job anywhere in the session’s tree keeps it, as [session].retention spares one. When false (default), only the in-memory state is dropped and the session can be re-attached later.

TypeDefault
boolfalse

serve.shutdown_drain_timeout

Maximum time to wait for in-flight turns and tasks to finish during graceful shutdown (SIGTERM / SIGINT). After this timeout, remaining tasks are aborted and the process exits.

TypeDefault
string (duration)"30s"

[[serve.tokens]]

An array of bearer tokens for API authentication. At least one token is required.

KeyRequiredDescription
tokenYes*The bearer token value. Supports ${ENV_VAR} substitution. Mutually exclusive with token_file.
token_fileYes*Path to a file containing the token (one line, trimmed). Mutually exclusive with token. A startup warning is logged if the file is world-readable.
descriptionNoHuman-readable label for this token (appears in logs).
scopesYesArray of scope strings. One :r and one :w per subsystem: sessions, skills, memory, schedule, mcp, config.

* Exactly one of token or token_file must be set.

Inline plaintext tokens log a startup warning; use ${ENV_VAR} or token_file for production.

Examples

Development token (inline):

[[serve.tokens]]
token = "sk_dev_test123"
scopes = ["sessions:r", "sessions:w"]

Production token (environment variable):

[[serve.tokens]]
token = "${MEKA_BRIDGE_TOKEN}"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]

Production token (file-based):

[[serve.tokens]]
token_file = "/etc/meka/bridge.token"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]

Admin token with every scope:

[[serve.tokens]]
token = "${MEKA_ADMIN_TOKEN}"
description = "operator"
scopes = [
    "sessions:r", "sessions:w",
    "skills:r", "skills:w",
    "memory:r", "memory:w",
    "schedule:r", "schedule:w",
    "mcp:r", "mcp:w",
    "config:r", "config:w",
]

Scopes are flat: memory:r does not imply memory:w, and neither implies the other. See the HTTP API scope table for what each permits. An unrecognized scope logs a warning at startup and grants nothing, so a typo like sessions:write is visible rather than silently inert.

[[serve.webhooks]]

Outbound endpoints meka POSTs to when something happens that no client is waiting on: a scheduled job firing, a background task finishing. Omit the block entirely and meka never makes an outbound request.

[[serve.webhooks]]
url = "https://bridge.example/meka-hook"
secret = "${MEKA_WEBHOOK_SECRET}"     # or secret_file = "/etc/meka/hook.secret"
events = ["turn.finished", "turn.failed", "task.finished", "schedule.fired"]
timeout = "10s"
max_retries = 3
KeyTypeDefaultNotes
urlstringrequiredhttps:// or http://; supports ${ENV_VAR}
secretstringnoneHMAC key for X-Meka-Signature; supports ${ENV_VAR}
secret_filepathnoneMutually exclusive with secret; chmod 0600
eventsarrayrequiredOne or more of turn.finished, turn.failed, task.finished, schedule.fired, inbox.deferred, inbox.delivered, inbox.failed
timeoutduration"10s"Per attempt; "0s" is refused at startup
max_retriesinteger3Retries after the first attempt, capped at 10

events is required and every name must be recognized. An unknown event is a startup error, not a warning, unlike an unknown token scope: a scope that grants nothing leaves the token working for whatever else it holds, whereas an endpoint whose only subscription is a typo is silently never called at all.

Payloads carry identifiers and metadata, never message content. Omitting secret sends unsigned deliveries and logs a warning. See Webhooks for the payload shape and the signature-verification recipe.