Lemon Configuration (TOML)
Lemon uses a single canonical configuration file in TOML format. Configuration is layered:
- Global:
~/.lemon/config.toml - Project:
<project>/.lemon/config.toml(overrides global) - Environment variables (override file values;
.envmay auto-populate missing env vars at startup) - Credential references resolved through Lemon's encrypted store, explicitly enabled external sources, and the ordinary same-name environment fallback
Runtime state and policy are separate from config. Per-session or per-route "current" model/thinking values override config defaults at runtime, but they are not persisted in config.toml.
Operational backup policy is also intentionally separate from TOML. Use lemon backup contract|create|list|verify|restore; there are no implicit backup directories or credential-inclusion settings in config.toml. See Back up and restore Lemon user state for the versioned ~/.lemon data contract.
Example
[providers.anthropic]
api_key_secret = "llm_anthropic_api_key_raw"
# Claude Max / Claude Code subscription auth
# auth_source = "oauth"
# oauth_secret = "llm_anthropic_api_key"
[providers.openai]
api_key_secret = "OPENAI_API_KEY"
# OpenAI-compatible local or hosted endpoint.
# Store a placeholder secret such as "local" if the endpoint ignores API keys.
# base_url = "http://127.0.0.1:11434/v1"
[providers.opencode]
api_key_secret = "OPENCODE_API_KEY"
base_url = "https://opencode.ai/zen/v1"
# OpenCode Go uses the same OPENCODE_API_KEY credential.
[providers.opencode_go]
api_key_secret = "OPENCODE_API_KEY"
[defaults]
provider = "anthropic"
model = "anthropic:claude-sonnet-4-20250514"
thinking_level = "medium"
[runtime.compaction]
enabled = true
reserve_tokens = 16384
keep_recent_tokens = 20000
# threshold_percent = 80 # optional: compact above 80% of the context window
# threshold_tokens = 150000 # optional: fixed token threshold (overrides percent)
[runtime.retry]
# Session-level retry of transient provider failures (429, 5xx, overloaded,
# network) that happen before any output. Exponential backoff from
# base_delay_ms; a retry-after hint from the provider replaces the backoff.
enabled = true
max_retries = 3
base_delay_ms = 1000
[runtime.secret_obfuscation]
# Opt-in. Replaces secrets in provider-visible session text with placeholders
# and restores them in tool arguments; see security/secret-obfuscation.md.
enabled = false
[runtime]
# Explicitly trusted extension directories. Files here can be compiled and executed.
extension_paths = []
# Optional cap on tool-using turns per prompt. Unset or 0 = unlimited (default).
# max_tool_turns = 200
[runtime.extensions]
# Disable all extension code execution while keeping manifest diagnostics available.
enabled = true
# Keep default global/project extension directories diagnostics-only unless trusted.
auto_load_default_paths = false
[runtime.tools.web.search]
provider = "brave" # "brave" | "perplexity"
cache_ttl_minutes = 15
[runtime.tools.web.search.perplexity]
model = "perplexity/sonar-pro"
[runtime.tools.web.fetch]
cache_ttl_minutes = 15
allow_private_network = false
allowed_hostnames = []
[runtime.tools.web.fetch.firecrawl]
enabled = true
[runtime.tools.wasm]
enabled = false
auto_build = true
runtime_path = ""
tool_paths = []
default_memory_limit = 10485760
default_timeout_ms = 60000
default_fuel_limit = 10000000
cache_compiled = true
cache_dir = ""
max_tool_invoke_depth = 4
[tui]
theme = "lemon"
debug = false
notifications = "auto" # auto | osc | bell | off (Bun terminal client)
prompt_history = true # persist ↑ prompt recall to ~/.lemon/tui-history/history.sqlite3 (Bun terminal client)
[logging]
# Optional: write logs to a file for later analysis.
# If unset/empty, file logging is disabled and logs go to stdout/stderr only.
file = "~/.lemon/log/lemon.log"
# Optional: handler level for the file (defaults to "debug").
level = "debug"
[gateway]
max_concurrent_runs = 2
auto_resume = false
enable_telegram = false
enable_discord = false
enable_xmtp = false
[gateway.telegram]
# `lemon gateway setup telegram` stores the token in encrypted secrets, pairs
# your chat via /start, and writes these three keys.
bot_token_secret = "telegram_bot_token"
# Required: chats allowed to use the bot. With no allowlist the bot is unpaired
# and ignores every chat (it only answers /whoami and a pairing hint).
allowed_chat_ids = [12345678]
# allow_all_chats = true # explicit opt-out: accept every chat when allowed_chat_ids is empty
# unauthorized_dm_behavior = "pair" # opt-in owner-approved codes for unknown human DMs
default_account_id = "default" # optional account for ./bin/lemon send account-scoped lookups
default_chat_id = 12345678 # optional default for ./bin/lemon send --to telegram
default_thread_id = 35 # optional forum topic/thread
[gateway.discord]
# `lemon gateway setup discord` stores the token in encrypted secrets and
# writes this secret reference plus the restricted channel scope.
bot_token_secret = "discord_bot_token"
default_channel_id = 123456789012345678
allowed_channel_ids = [123456789012345678]
deny_unbound_channels = true
# allowed_guild_ids = [123456789012345678] # optional additional restriction
# unauthorized_dm_behavior = "pair" # opt-in; DMs require an explicit allowed_channel_ids entry
[gateway.xmtp]
env = "production" # production | dev | local
wallet_address = "${XMTP_WALLET_ADDRESS}"
wallet_key_secret = "xmtp_wallet_key"
db_path = "~/.lemon/xmtp-db"
poll_interval_ms = 1500
connect_timeout_ms = 15000
require_live = true
mock_mode = false
[gateway.voice]
enabled = false
websocket_port = 4047
public_url = "https://example.com"
twilio_account_sid_secret = "twilio_account_sid"
twilio_auth_token_secret = "twilio_auth_token"
twilio_phone_number = "+1234567890"
deepgram_api_key_secret = "deepgram_api_key"
elevenlabs_api_key_secret = "elevenlabs_api_key"
elevenlabs_voice_id = "21m00Tcm4TlvDq8ikWAM"
elevenlabs_output_format = "ulaw_8000"
llm_model = "gpt-4o-mini"
max_call_duration_seconds = 600
silence_timeout_ms = 5000
[profiles.default]
name = "Daily Assistant"
system_prompt = "You are my daily assistant."
[profiles.default.tool_policy]
# Optional preset profile:
# profile = "minimal_core" # full_access | minimal_core | read_only | safe_mode | subagent_restricted | no_external | custom
allow = "all"
deny = []
require_approval = ["bash", "write", "edit"]
no_reply = false
[[gateway.bindings]]
transport = "telegram"
chat_id = 12345678
agent_id = "default"Tool policies are parsed strictly by LemonCore.ToolPolicy. Unknown profiles, unknown or conflicting keys, malformed allow/deny/block/approval values, and unsupported approval modes fail closed rather than falling back to full access. Omitting [profiles.<id>.tool_policy] retains the explicit runtime default; supplying an invalid table produces a deny-all policy.
LemonCore.ProfileStore is the lifecycle owner for user-managed profiles. Commands such as lemon profile create research patch only the selected [profiles.<id>] table, preserving unrelated global-config keys and comments. Managed records may include name, description, avatar, model, system_prompt, node, status, profile_version, created_at, and updated_at. Home/workspace paths and the stable agent:<id>:main session key are derived from the validated ID rather than stored; do not add credentials or filesystem paths to the profile table. See User-managed profiles for clone, credential-safe export, recoverable deletion, and named-node routing.
Automatic model routing
Automatic per-turn model routing is opt-in. It is disabled by default (enabled = false, or the table omitted). Configure it in the global ~/.lemon/config.toml or project .lemon/config.toml; project tables override global keys (the rules array is replaced as a whole, not merged):
[model_routing]
enabled = true
max_cost_usd = 0.05 # required when enabled; finite, >= 0
[[model_routing.rules]] # ordered; first matching rule wins
model = "@smol" # any model selector, including [model_roles] aliases
contains = "typo" # optional; case-insensitive substring of latest user text
max_chars = 400 # optional; inclusive, nonnegative integer
[[model_routing.rules]]
model = "anthropic:claude-sonnet-5"
min_chars = 401 # optional; inclusive, nonnegative integerSemantics:
- Rules are evaluated in order against the latest user text. A rule matches when every matcher it provides (
contains,min_chars,max_chars) holds (AND); a rule without matchers always matches. The first matching rule picks the candidate model; later rules are never consulted as a fallback. - If no rule matches, the session's default model is used.
- Every selected model, including the default, is checked against
max_cost_usd. The check is a per-request projection from catalog pricing using a conservative bytes-based estimate of prompt plus tools and the maximum output; it is not a cumulative invoice guarantee. Anthropic manual thinking can expand the requested output limit, so those requests use the catalog maximum instead of the smaller caller limit. A model with unknown or all-zero pricing, or without a bounded maximum output, fails closed and the request is refused rather than silently routed elsewhere. Configured model price overrides must explicitly supplyinput,output,cache_read, andcache_writerates (including genuinely zero rates); omitted rates are unknown, not free. Configured additions whose exact provider and model ID are absent from the catalog also require complete explicit pricing; prices inherited from another model's template are not accepted. - An explicit user-selected model (for example
/modelor an explicit override) always wins: it bypasses automatic routing and this cap. Explicit choices survive session resume. Automatic selection stays automatic across reset, save, and resume. Tree/entry navigation and committed rewinds restore the destination branch's selection provenance: automatic branches keep their rules and cap, while explicit branches keep their bypass. Older saved model selections without provenance are conservatively treated as explicit; start a new session to apply automatic routing to those conversations. Inherited/default task, coordinator, background, and side-query models preserve that provenance; an explicit child model override still bypasses routing. Personamodel/roledefaults and[runtime.task.agent_models]are automatic defaults, not explicit child overrides: task, coordinator and router-backedagentdelegations still apply the rules and cap unless the invocation supplied a model or inherited an explicit parent selection. Profilemodeldefaults (includingprofile.chat,chat.sendwith a profile, and model-less managed follow-ups) are automatic as well. An invocation model override remains explicit; legacy requests supplying a model without selection provenance also retain their explicit-selection behavior. Automation reviewer persona/role and configured reviewer/judge defaults retain automatic provenance; explicit job model pins and invocation overrides remain explicit. Entering and leaving plan mode (including resuming a saved plan) also preserves the execution model's automatic policy or explicit selection. [budgets]cumulative caps are separate and still apply.- Assistant messages record the actual turn's
provider/model. Each completed provider-invoked model call also emits aModel: provider:modelactivity entry in the TUI/web transcript; the run footer uses the last actual model rather than the requested default. Provider fallback candidates are checked against the same cap, and a candidate rejected before invocation is not model activity.
Model selectors and rule values are validated when a session starts, not by the config loader; invalid routing config raises a safe configuration error.
Cumulative usage budgets
Set optional caps in the global ~/.lemon/config.toml or project .lemon/config.toml (project values override global values):
[budgets]
max_run_tokens = 100000
max_run_cost_usd = 2.50
max_session_tokens = 500000
max_session_cost_usd = 10.00Omitted limits are unlimited. Token caps are nonnegative integers; dollar caps are finite nonnegative numbers. Zero blocks requests, unlike an omitted limit. A run is one prompt: its counters reset on the next prompt. Session counters accumulate across prompts and durable resumes. They are separate from context-window occupancy: compaction, rewind, branch navigation and clear do not refund spend. Forks inherit the source session's entire cumulative ledger, including spend outside the selected branch; subsequent spend is independent. A genuinely new, unrelated session starts a new session budget.
These caps apply to the prompt agent loop, including its reported retry attempts and native task/swarm descendants. Child usage updates and completion reports count toward the parent's run and session limits exactly once; an exhausted parent cannot launch further children. Running children check ancestor limits before further requests/tools. An already in-flight request can overshoot a cap, including requests from concurrently running children.
Descendant spend is stored separately for budget enforcement: each session's usage reports and billing records still contain only its own provider usage. Late async child usage charges the originating parent session, not a later prompt's run cap. Compaction and project-memory maintenance model calls are not counted. This is not an account-wide billing limit and does not cover router-delegated agent runs. Models with all-zero or missing catalog pricing emit a budget warning when a dollar cap is configured; requests remain allowed, so configure model pricing or token caps to bound unpriced usage. Task scheduler concurrency settings still apply; there is no hidden three-child or projected $0.10 budget gate.
Tokens use the provider's reported total, or input + output + cache-read + cache-write when a total is unavailable. Long-lived cache writes are a subset of cache writes, not an additional token charge. Dollar amounts are catalog-based estimates, not billing invoices. Reported usage from interrupted requests and retries counts while enforcing a cap; usage the provider never reports cannot be invented. Legacy sessions derive their starting ledger from all stored assistant messages, including inactive branches.
Limits are checked before a request and after reported usage, before another request or tool execution. Before sending, Lemon projects the next request's input plus maximum output tokens and catalog cost against both run and session caps. Input uses the latest measured prompt size in the outgoing history, or roughly one token per four characters of the system prompt, messages and tool schemas. Output uses the request's max_tokens, or the model default. Cached input is conservatively priced as ordinary input; catalog long-context prices apply when the estimated input crosses their threshold.
A request is refused when recorded usage plus its projection exceeds a cap; projections are not charged to the usage ledger. Unknown/zero model pricing leaves dollar projections unavailable (the existing warning still applies), but token projections still work. These estimates are stopping policies, not hard billing guarantees: tokenization, cache writes and concurrent children can still cause actual usage to exceed a cap. Already-exhausted or zero caps prevent requests. Tool calls requested by the response that exhausts a cap are recorded as rejected error results without executing their side effects. Increase the configured cap if you intentionally want to continue an exhausted session.
Native Execution and Subagents
Lemon uses its native executor for every top-level TUI and gateway run. Configure its provider, model, and thinking defaults in [defaults]; no engine selection key is supported.
Delegated tasks run as native in-process subagents (child CodingAgent.Session executions) when the agent invokes its task tool. There are no vendor CLI task runners and no [runtime.cli.*] configuration.
Model Roles
[model_roles] is a string-to-string map in the same canonical config, layered global → project like other sections. Roles are explicit selectors, not model IDs or subagent personas:
[defaults]
model = "@default"
[model_roles]
default = "openai:gpt-4o"
smol = "openai:gpt-4o-mini"
slow = "@review"
plan = "@slow"
commit = "@smol"
review = "anthropic:claude-sonnet-4-20250514"
task = "@smol" # Optional: omitted task.model otherwise inherits its parent
compaction = "@smol" # Optional: takes precedence over configured smolNative session model overrides and explicit task.model accept @default, @smol, @slow, @plan, @commit, and any configured custom role. Aliases expand recursively. Concrete model specs retain their existing behavior; bare smol is still a model ID, not the @smol role. Role names remain strings and are never converted into atoms. Empty/non-string mappings, unknown custom aliases, and alias cycles produce configuration/model-resolution errors rather than silently ignoring an override. A configured role still needs to resolve to a valid concrete model.
Without a mapping, these built-ins use the configured default for a native session and the current parent model for a task. They do not select a new provider or a hard-coded small/slow model. A default of @default requires a role mapping that ultimately reaches a concrete model; it cannot fall back to itself. Omitting task.model preserves parent inheritance unless model_roles.task is configured; an explicit task model takes precedence.
Entering plan mode automatically selects model_roles.plan only when configured, whether the native session is live, stopped, or being created before its first run. The stopped path uses the saved session's project configuration and journals the planning model without starting an agent or requesting a model response. Project overrides still take precedence over user-global roles; a profile's bootstrap workspace does not replace the session's project configuration. Exiting or approving a plan restores the execution model recorded on entry, including after durable session resume. Invalid planning models fail before the mode, selected model, branch, or new-session resume binding changes. @commit is available to explicit model selectors and delegated tasks; it does not automatically invoke a commit tool.
Compaction and abandoned-branch summaries choose model_roles.compaction first, then model_roles.smol only if configured, otherwise the active session model. An explicit compaction :model option takes precedence. Summary credentials and provider stream options are resolved for that selected model together, including background compaction, without changing the main session's model. Explicit summary credential/stream-option overrides are retained.
Native Task Subagents ([runtime.task])
[runtime.task] tunes the native task tool. Like other sections it layers global → project; every key is optional:
[runtime.task]
max_depth = 2 # -1 unlimited; 0 = the main session cannot delegate
max_runtime_ms = 0 # per-child wall-clock limit; 0 disables it
idle_ttl_ms = 300000 # park a completed live child after 5 min; 0 keeps it idle
request_budget = 200 # soft assistant-request budget per run; 0 disables it
request_budget_notice = true # send one wrap-up steer at the soft budget
worktree_merge = "none" # default merge for worktree: true tasks: none | patch | branch
disabled_agents = ["sonic"]
[runtime.task.agent_models]
reviewer = "@slow" # model or @role per persona IDmax_depth: nesting limit. The main session is depth 0 and each child is one deeper; a session may delegate only while its depth is belowmax_depth, and children created at the cap never receive thetasktool.max_runtime_ms: wall-clock limit for each child session; the child fails with a timeout error when it is exceeded.request_budget: completed assistant requests per child run, including schema correction requests. At the soft limit, the child receives one concise wrap-up steering message (unlessrequest_budget_notice = false). Atceil(1.5 × budget)it is gracefully stopped and returns its latest visible output as partial, withdetails.budget_exhausted = true,request_count, and a stop annotation.0disables both thresholds. The task's optionalrequest_budgetparameter overrides this default per item, including batch/swarm children. Woken and revived children start a fresh count. Invalid structured output at exhaustion returns an explicit error result with the partial text, without correction retries; valid JSON is still decoded intostructured_output.idle_ttl_ms: how long a successfully completed child stays live and idle. After the TTL, Lemon releases its session but retains its transcript and address.0keeps it idle until the parent session ends. A directwrite agent://<id-or-name>wakes an idle child or revives a parked child with the same session ID, prior conversation, cwd/worktree, tools and model. The receipt'sdeliveryiswoken,revived, orinjected(already running); a failed worker launch reportsfailed. Replies use async auto-delivery, including children originally launched synchronously. Concurrent messages share one revival. Broadcasts still target only running/queued relatives. Cancelled/killed children, failed children, and children whose cwd/worktree or transcript was removed refuse revival and point tohistory://<id>. Revival is scoped to the existing parent task family, not daemon restart.worktree_merge: what a successfulworktree: truechild does when the call omitsmerge:nonekeeps the worktree,patchapplies the child's changes to the parent checkout,branchcommits them to a kept review branch.disabled_agents: persona IDs hidden from the tool's agent list and refused at launch.agent_models: per-persona model or@rolethat replaces the persona's ownmodel/role; an explicit taskmodelstill wins.
Automatic Thinking
Set [defaults] thinking_level = "auto" to classify each user prompt before its model turn. Configure model_roles.tiny or model_roles.smol explicitly for the classifier; tiny takes precedence. The classifier receives bounded latest-user text with no tools, and selects low, medium, high, or xhigh. Its effort is mapped to the active model's declared supported levels, so auto is never sent to a provider. Models without reasoning support receive no reasoning option.
Classification runs in the supervised agent task, not the session server, with a five-second deadline. A missing classifier, failure, timeout, or invalid answer uses supported medium effort and continues the turn. An explicit ultrathink request selects the active model's highest supported effort without a classifier call. The configured auto selector persists across turns and session resumes; the internal classifier response is not appended to the conversation. Classifier calls are separate maintenance usage, not part of the main agent-turn budget ledger. An already-exhausted main-response budget prevents classification.
Named Execution Nodes
The agent tool can route a delegated run to an authenticated execution node with its optional node parameter. Omit node or use "local" for the controller host. A named value must be online in the controller's live node registry. This placement is a per-run tool parameter, not a TOML engine or provider setting:
{
"action": "run",
"agent_id": "default",
"prompt": "Run the focused checks.",
"node": "worker-1"
}Join a destination from a source checkout with:
LEMON_NODE_OPERATOR_TOKEN=... ./bin/lemon node join \
--name worker-1 \
--controller wss://controller.example/ws \
--pair \
--cwd /srv/projectOn first connection, --pair creates the durable controller identity and stores its issued seven-day session plus recovery credential in a mode-0600 file under ~/.lemon/nodes/execution/; the containing directory is mode 0700. Each record is keyed by a hash of the durable node ID and includes the exact controller URL, so reuse fails closed for a different controller. The token store requires that exact URL even for direct durable-ID lookup and returns no recovery material for a missing or mismatched controller. Later starts omit --pair. Re-run with --pair after session expiry to keep the same identity and controller-side name while rotating the session and revoking older tokens and live sockets. Concurrent challenge exchange can mint only one credential, and result settlement is accepted only from the connection and session generation that received the invocation. Controller renames do not change the local key. Compatible legacy records without recovery credentials use the explicit operator-authorized --pair --repair --node-id ID migration path.
LEMON_NODE_OPERATOR_TOKEN supplies pairing authority and is used only during pairing. LEMON_NODE_TOKEN supplies an existing session token. Prefer these environment variables to --operator-token / --token so credentials do not enter shell history. These values are runtime CLI inputs, not config.toml keys.
LEMON_NODE_CAPABILITIES (or --capabilities) sets the destination-local tool ceiling to all or a comma-separated list such as read,write,bash. Every remote execution context is intersected with this ceiling before the local executor starts.
Non-loopback controllers require wss:// by default. Plaintext ws:// needs --allow-insecure-controller or LEMON_NODE_ALLOW_INSECURE_CONTROLLER=true, and that override is acceptable only for development or a verified authenticated and encrypted overlay such as Tailscale.
Provider credentials and default cwd are destination-local. If the agent tool omits cwd, the destination uses the directory passed to node join; an explicit cwd is resolved and validated on that machine. Only JSON-safe run data crosses the controller WebSocket. Source executor options, resolved provider credentials, callbacks, and BEAM state do not. Explicit cancellation is delivered to the targeted worker; disconnects fail pending invocations. Remote execution still uses the native CodingAgent.Executor, never a vendor CLI runner.
Removed Top-Level Engine Configuration
Remove these legacy keys and tables from global and project configuration:
| Removed configuration | Migration |
|---|---|
engine, [defaults].engine, default_engine, and engine_preference | Remove the key. Keep [defaults] provider, model, and thinking settings for native execution. |
[gateway].default_engine, [gateway.projects.<id>].default_engine, and [[gateway.bindings]].default_engine | Remove the key. Retain project root, binding project/agent_id, and queue settings as needed. |
[gateway.engines.<id>] | Remove the entire table. Custom and external gateway executors are not supported. |
[runtime.cli.<vendor>] | Remove the entire table. Vendor CLI task runners were removed; delegated tasks run as native in-process subagents. Config validation rejects [runtime.cli]. |
There is no TOML replacement for selecting a top-level external or custom engine. Delegated subagents always run natively; [runtime.task] (above) only tunes depth, runtime, merge-back and persona availability/models.
External MCP Configuration
Lemon imports project MCP definitions from supported coding-tool configs; Lemon's own .lemon/mcp.json entries take precedence. Other tools' user-global configs require LEMON_MCP_EXTERNAL_USER_DISCOVERY=1 (the omp user config is read by default, and CLAUDE_CONFIG_DIR opts in Claude user files). Disabled entries still claim their names; importing configuration does not launch an MCP server. See MCP management.
OpenCode opencode.json and opencode.jsonc files accept comments and trailing commas. After JSONC decoding, Lemon substitutes {env:NAME} and {file:path} once within string values, including command arguments, environment values, URLs and headers; object keys are not interpolated. Environment values retain their exact bytes, including quotes, backslashes, newlines and leading/trailing whitespace. An unset environment variable substitutes an empty string.
File placeholders resolve relative to the containing config file, accept absolute paths, and resolve ~/ against the discovery home when one is available. File contents are trimmed of leading/trailing whitespace; a missing or unreadable file (or ~/ without a discovery home) substitutes an empty string. Neither inserted environment values nor inserted file contents are interpreted again as JSON, interpolation directives or file imports.
Environment Overrides
Environment variables override file values. Common overrides:
LEMON_DEFAULT_PROVIDER,LEMON_DEFAULT_MODELLEMON_CONTROL_PLANE_OPERATOR_TOKEN— shared WebSocket operator credential; required by default for every WebSocket operator connection. Named node pairing supplies the same value fromLEMON_NODE_OPERATOR_TOKENon the joining host. The source./bin/lemon-tuilauncher generates an ephemeral value when it owns a fresh local runtime; persistent or existing runtimes require the operator to provide the same high-entropy value to both runtime and client. Token origin does not control process lifetime: every runtime started by./bin/lemon-tuiis stopped with the TUI. Start a persistent source runtime with./bin/lemon daemon, then attach with the same token.LEMON_CONTROL_PLANE_ALLOW_UNAUTHENTICATED_LOOPBACK— explicit legacy compatibility opt-in (true/falseor1/0) for tokenless operator connections from direct loopback peers. Defaults tofalse; never enable it for a reverse-proxied control plane.
The browser monitoring client intentionally has no build-time or URL-based operator-token setting. Lemon does not yet implement the short-lived delegated browser-session exchange needed to authenticate that client safely. Never place the shared server token in VITE_* configuration.
LEMON_PROVIDER_ROUTING_ENABLED,LEMON_PROVIDER_FALLBACK_PROVIDERS,LEMON_PROVIDER_ROUTING_REQUIRE_CREDENTIALSLEMON_TUI_THEME,LEMON_TUI_DEBUG— TUI appearance and debug modeLEMON_TUI_NOTIFICATIONS=auto|osc|bell|off— overrides the Bun TUI's global[tui] notifications; legacy0meansoff,1meansautoLEMON_SECRET_OBFUSCATION_ENABLED— overrides[runtime.secret_obfuscation] enabled<PROVIDER>_API_KEY,<PROVIDER>_BASE_URL(e.g.,ANTHROPIC_API_KEY,OPENAI_BASE_URL,OPENCODE_API_KEY,ZAI_API_KEY,MINIMAX_API_KEY); local servers useOLLAMA_BASE_URL/OLLAMA_HOST,LM_STUDIO_BASE_URL,LLAMA_CPP_BASE_URLandLITELLM_BASE_URLonly when config sets nobase_url(see OpenAI-Compatible Endpoints)LEMON_WASM_ENABLED,LEMON_WASM_RUNTIME_PATH,LEMON_WASM_TOOL_PATHS,LEMON_WASM_AUTO_BUILDLEMON_TERMINAL_BACKENDS_ALLOW,LEMON_TERMINAL_BACKENDS_DENY,LEMON_TERMINAL_BACKENDS_REQUIRE_APPROVALLEMON_DOCKER_TERMINAL_IMAGE,LEMON_DOCKER_TERMINAL_MEMORY,LEMON_DOCKER_TERMINAL_CPUS,LEMON_DOCKER_TERMINAL_PIDS_LIMIT,LEMON_DOCKER_TERMINAL_NETWORKLEMON_DOCKER_TERMINAL_READ_ONLY_ROOTFS,LEMON_DOCKER_TERMINAL_TMPFS_SIZE,LEMON_DOCKER_TERMINAL_ALLOWED_IMAGESLEMON_SSH_TERMINAL_TARGET,LEMON_SSH_TERMINAL_WORKDIR,LEMON_SSH_TERMINAL_PORT,LEMON_SSH_TERMINAL_CONNECT_TIMEOUT,LEMON_SSH_TERMINAL_STRICT_HOST_KEY_CHECKING,LEMON_SSH_TERMINAL_ALLOWED_TARGETSLEMON_GATEWAY_HEALTH_PORT,LEMON_ROUTER_HEALTH_PORTLEMON_NODE_OPERATOR_TOKEN,LEMON_NODE_TOKEN,LEMON_NODE_ALLOW_INSECURE_CONTROLLER,LEMON_NODE_CAPABILITIESLEMON_LOG_FILE,LEMON_LOG_LEVELBRAVE_API_KEY,PERPLEXITY_API_KEY,OPENROUTER_API_KEY,FIRECRAWL_API_KEY
Terminal backend policy validates Docker image/network/resource settings and SSH port/timeout/host-key settings before exec launches a backend. Invalid Docker limits, invalid Docker image/network names, invalid SSH ports, invalid SSH connect timeouts, and unsupported strict-host-key values fail closed at the policy boundary instead of reaching Docker or OpenSSH.
Background exec launches fail closed when admission is unavailable: there is no direct-execution fallback, including exec_sync or internal callers passing use_lane_queue: false. Each logical launch has a fixed identity, bound to its session owner and a validated request fingerprint. Reusing that identity with the same request reconciles the original receipt; changing the request or owner is a conflict. Intentionally running the same command twice creates two identities. Accepted receipts track the existing process. A missing reply or lost process owner can produce outcome_unknown, which is not a rejection and must not trigger automatic replay. Inspect/reconcile the fixed identity; any recovery that repeats external effects must be explicitly requested. Receipt projections exclude commands, environment values, and exception details. Clearing completed output retains a small redacted identity tombstone so clearing logs cannot authorize a duplicate launch.
Background process admission
Background process admission limits are umbrella application configuration, not TOML or agent-controlled tool inputs:
config :coding_agent,
background_process_limit: 8,
background_pending_limit: 32,
background_admission_timeout_ms: 5_000,
background_admission_wait_ms: 1_000The process limit holds capacity for the managed command's live lifetime, not just the launch callback. lane_caps.background_exec remains a separate launch concurrency cap; main/subagent lanes retain their existing callback semantics. The pending limit bounds waiting admissions per LaneQueue; the acceptance ledger also bounds in-flight registrations. Process/pending limits are integers up to 1,000,000 (process minimum 1, pending minimum 0). Admission deadline/wait values must be integers from 1 to 86,400,000 milliseconds. Invalid settings fail explicitly at startup, without echoing the invalid value.
The caller wait budget yields {:queued, identity} independently of execution lifetime. Queued cancellation, deadline expiry, or abandonment prevents later start. An explicitly supplied owner_pid keeps a deliberately yielded queued launch owned after the submitting tool task returns; losing that owner cancels pending work. Accepted work outlives its transient submitting caller, but is signaled when its supplied owner exits. Without owner_pid, accepted work deliberately continues until completion, explicit cancellation, or its separately requested execution timeout. Use foreground commands with background execution, not shell daemonization, for an inspectable managed lifetime.
Cancellation is a request: :killing does not free capacity. On local Unix launches, the runtime binds and persists an isolated process-group identity before permitting the command to execute. A root shell's Port exit is not a workload-termination witness: surviving descendants keep an outcome_unknown receipt and the lifetime slot. The owning session reconciles actual group membership and releases once only after no live members remain. It retains the group through root exit so later cancellation can target verified descendants, never a recycled positive root PID. Lost ownership or failed observation remains unknown and charged, not a license to signal an unverified group. Before forwarding stdin that may end the root, the runtime durably retains live members observed under the root's matching identity. After root exit, verified cancellation requires a surviving previously bound member with matching birth identity. If the root and all previously bound members disappear before another member can be bound, the numeric PGID alone is insufficient proof: cancellation remains unknown and charged, requiring independently verified recovery.
A host process group cannot prove termination of remote SSH/container work, PTY work in a separate session, or Windows job descendants; wrapper exits in these cases conservatively remain unknown and charged. Repeating cancellation after the host wrapper disappears still requires the backend-aware full-workload witness; an empty host group cannot confirm remote or separately contained work. A lost BEAM process owner also retains its persisted identity and capacity. Nothing is automatically relaunched. Queue/store restart reconciles surviving registered processes before admission. The internal operator API ProcessManager.confirm_external_termination(id, owner) may release unknown capacity only after independently verifying termination of the entire owned workload, not merely the root shell; it is an assertion, not a liveness probe or agent tool, and refuses a launch still starting. The original uncertain receipt remains uncertain.
ProcessManager.admission_counts/0 exposes bounded active, queued, rejected, expired, canceled, unknown, and occupied counts, with no commands or secrets. Unknown historical outcomes are distinct from currently occupied capacity. Receipt/count lookup failures report unavailability, never an empty/idle state. Synchronous completion-wait expiry retains the fixed identity as {:error, {:outcome_unknown, identity}}; reconcile instead of rerunning. Direct ProcessSession.start_link is an internal/test primitive, not an agent admission bypass. This contract prevents duplicate managed starts for an identity; it does not claim exactly-once external side effects.
Unknown Bash launch receipts are tool errors with retained process_id, status: "unknown" and classification: "outcome_unknown", not failed-start receipts. Startup rejections preserve their actual pre-execution failure reason. Every durable load, including late permission recovery or reopening, restores capacity and live owner monitors before permitting a new admission.
Cleared identity tombstones and historical unknown receipts do not expire. The default finished-process TTL (86,400 seconds) and cleanup interval (300 seconds) do not bound ledger rows; recovery, count and artifact scans grow with that history. Observable retained local workloads reconcile after 100 ms plus scan cost using a system-wide ps snapshot. Ownership-lost and nonlocal unknown workloads do not schedule this polling. These are local containment contracts, not proof of remote workload termination.
The internal evaluation harness can additionally opt into LemonCore.NativeProcess tracking by calling configure!/1 with a fresh private ledger path before application boot. This is independent of managed-process admission and is not a user TOML setting. With no ledger configured, native launches keep their existing behavior. Tracked Bash and external-secret launches sync an intent before opening a stdin-gated local wrapper, then sync its birth-bound group witness before releasing execution. Configuration durably establishes the ledger file and newly created directory entries; acknowledged records use stable-storage barriers, including F_FULLFSYNC on Darwin. Existing ledger paths are refused without DETS repair. A missing ledger service, failed persistence or unavailable witness refuses release rather than falling back to an untracked launch. Ledger snapshots retain uncertain or unsupported workloads as occupied; an empty group is termination evidence only for a valid, unlost witness. PTY helpers can create separate sessions, so their wrapper group alone is not a whole-workload termination proof. Shared LemonCore.ProcessGroup signalling uses an absolute OS executable and fixed argv, never a PATH-selected binary or shell command.
The Bun TUI uses the runtime's model and thinking defaults from [defaults]. The [tui] section controls terminal presentation settings loaded at startup. The Bun client reads global ~/.lemon/config.toml with project .lemon/config.toml overrides at startup. LEMON_TUI_NOTIFICATIONS takes precedence over notifications. auto (default) uses OSC 9/777 on recognized terminals and BEL (\x07) elsewhere. osc forces OSC without a bell fallback, bell uses BEL only, and off disables both. Pending ask questions, approvals, and run completion share this mode and use fixed notification text, never question or conversation content. Tern's program-status inbox/banners remain controlled by Tern's settings; bell explicitly rings there too. Non-TTY, TERM=dumb, or LEMON_TUI_TERMINAL_INTEGRATION=0 suppresses these side channels entirely. See clients/tui/README.md for copy commands and keyboard access.
TUI keybindings
Use [tui.keys] to replace any action's default keys. Quote action IDs (otherwise TOML interprets dots as nested tables). Project bindings override global bindings per action, not the entire key table.
[tui.keys]
"lemon.palette.open" = "alt+p"
"lemon.plan.toggle" = ["alt+shift+p", "ctrl+shift+p"]
"lemon.model.cycle" = "alt+v"
"lemon.picker.thinking" = [] # unbind; "none" also worksEach value is one key string or an array. [] and "none" unbind the action. Chords use ctrl, shift, alt, or super, followed by + and a pi-tui key: a letter, digit, punctuation symbol, or named key such as enter, escape, tab, space, backspace, delete, insert, home, end, pageUp, pageDown, arrows, or f1–f12. Key names are case-insensitive. Modified keys depend on your terminal's keyboard protocol; some terminals cannot distinguish every chord.
The key reference lists every bindable action ID and its defaults, including pi-tui editor and selector actions. Lemon's local composer policies (abort/quit, history recall, attachments and submission-mode cycling) and picker-local shortcuts remain separate. lemon.plan.toggle runs the same toggle as /plan; lemon.model.cycle uses the model picker's cached models.list order, wraps at the end, and changes only the current session (not the global default). Both start unbound.
Unknown actions, invalid values, and conflicts are ignored and combined into one startup notice; rejected actions retain their defaults. Conflicts include a user binding that collides with another action's unchanged default, so unbind or remap the original owner in the same table before reusing its key. Existing context-specific default overlaps are retained. Without [tui.keys], all existing defaults stay unchanged. These client-owned settings are accepted by lemon config validate; action/key validation happens in the TUI at startup.
TUI prompt history
The Bun TUI remembers submitted prompts across sessions for ↑ / ↓ recall. Persistence is on by default. Only submission writes prompt records: typing, recalling and discarding drafts do not persist them. Discarded drafts remain recallable in the current session's in-memory ring.
History is a local SQLite database at ~/.lemon/tui-history/history.sqlite3 ($HOME/.lemon). It uses Bun's built-in SQLite, not a daemon, external database service or third-party storage package. The history directory must be owned by the current user and mode 0700; database and any SQLite sidecars are mode 0600, even with a permissive umask. Existing loose file modes are tightened. Symlinked or hardlinked history files, symlinked user-directory components, foreign-owned files and insecure directories are rejected rather than followed. An existing .lemon directory may be 0755, but must not be writable by other users.
Each append, oldest-first prune and clear is a short SQLite BEGIN IMMEDIATE transaction. Records are ordered by the database's transaction serialization, not file names or modification times. Concurrent TUIs cannot overwrite one another from stale recall snapshots. An adjacent repeat is suppressed against the last committed prompt. SQLite's busy timeout is 1.5 seconds per lock wait: contention fails with an actionable notice rather than polling indefinitely. Storage errors leave this session's in-memory recall usable.
The retained-history bounds remain 100 prompts and 256 KiB (UTF-8 bytes of the JSON-encoded prompts including one newline each), dropping the oldest first. A prompt larger than 256 KiB is session-only. These are logical content limits, not a byte-exact filesystem quota. SQLite has page, index and schema overhead. The database uses full auto-vacuum to return freed pages at commit, a page-count allocation ceiling with room for overhead and an in-flight append, and DELETE rollback journaling with a zero retained-journal limit. Connections close after each operation; normal operation creates no growing WAL/SHM files. Temporary rollback journals also consume disk space during transactions. This policy bounds growth without claiming an unproven hard cap on database plus sidecar bytes. SQLite FULL synchronous commits and rollback-journal recovery protect committed records after a process crash.
On first use, the TUI imports bounded tails of legacy ~/.lemon/tui-history.jsonl, its numbered backups and timestamp/pid/random suffix files. Older files are imported in a deterministic order, current JSONL last; the original cross-file transaction order cannot be reconstructed. Invalid lines are skipped and the same retention bounds apply. The import and a one-time migration marker commit together before the sources are removed. A crash during cleanup cannot import them twice. Close older TUI versions before upgrading: they do not participate in SQLite transactions.
Prompts may contain secrets. The database stays on your machine; the daemon never reads it and doctor support bundles do not include it. lemon backup copies durable ~/.lemon state, so clear history first if you do not want prompts in a backup. Clearing is not cryptographic erasure of old backups, filesystem snapshots or previously deleted blocks.
/clear-historyempties this TUI's recall rings and transactionally deletes saved rows, sets the migration fence and removes retained legacy sources, even with persistence disabled. The SQLite database stays in place so open writers cannot split onto an unlinked database. A concurrent append committed before clear is removed; one serialized after clear may survive. No stale client snapshot or delayed legacy cleanup can resurrect cleared records. Other TUIs' in-memory recall rings are not erased. Permission, lock and cleanup failures report an error, never a successful clear; a failed legacy cleanup may leave source files on disk, but the committed migration fence prevents reimport. Retry/clear-historyafter fixing the reported problem.[tui] prompt_history = false(global or project config) turns persistence off: load and append do no history I/O, and recall lasts for the current session only. Any other non-truevalue also turns it off. Existing history remains until/clear-history. If config cannot be loaded (for example a project config syntax error), the TUI warns and keeps persistence off for that run.
[tui]
prompt_history = falseAsync followup queue defaults for background task completions and delegated agent completions are currently umbrella app config, not TOML. The default lives in config/config.exs as config :coding_agent, :async_followups, default_queue_mode: :steer_backlog. Per-call tool inputs still override it: task.queue_mode and agent.followup_queue_mode.
Background process output capture
Background bash capture limits are umbrella application configuration, not TOML or environment overrides:
config :coding_agent, :process_output,
max_retained_bytes: 1_048_576,
max_artifact_bytes: 16_777_216,
max_owner_artifact_bytes: 134_217_728,
max_runtime_artifact_bytes: 536_870_912ProcessManager.exec/1 and ProcessSession.start_link/1 accept the same per-call options, which override application defaults. Every byte limit must be a positive integer; invalid limits refuse launch before opening an output file or running the command. max_log_lines remains an additional positive line-count bound (default 1,000).
Capture is bounded, not backpressure or cancellation. The process continues running when capture fills; its status and external effects are unchanged. The private 0600 artifact keeps the head of output, at most the capacity reserved before launch. The in-memory diagnostic buffer keeps the tail, at most max_retained_bytes including held partial lines and consumed newlines. Lines longer than min(65_536, max(max_retained_bytes - 1, 1)) bytes are emitted as segments. A one-byte budget charges a segment and its newline separately. Old lines are evicted before adding bounded copied bytes. The process/store's retained binary payload is at most twice the per-process memory budget; stored logs mirror the process tail, not an independent ring. Parsing scans bounded windows without concatenating or splitting an entire incoming chunk. Additional live binary scratch is at most two line segments. This is a reachable-output-payload bound, not a bound on BEAM heap/GC overhead: the received port message, polling result copies, transport buffers and subscriber/mailbox accumulation remain outside it (subscriber fanout is #88).
poll, read_output, and bash_output expose structured output counters: received_bytes, retained_bytes, discarded_bytes, artifact_bytes, artifact_capacity, artifact_discarded_bytes, artifact_truncated, artifact_error, artifact_size_unknown, artifact_reclaimed, and finalized. Retained-byte costs include newlines even though displayed lines omit them. received_bytes = retained_bytes + discarded_bytes; for a known artifact size, received_bytes = artifact_bytes + artifact_discarded_bytes. Filling an artifact exactly is not truncation until another byte is received. An incomplete artifact is labelled Partial output, never full output.
Reservations are serialized by the existing process store. Live captures charge their full reserved capacity, so concurrent sessions cannot overfill the owner/runtime budgets. Finalization returns unused capacity, charging the bytes actually saved. When admitting another capture, the store reclaims stopped sessions' artifacts oldest first within the owner, then runtime-wide if needed. Live sessions' files are not reclaimed; if their reservations consume a budget, the new capture receives a smaller capacity, possibly zero. Ownerless sessions share one owner budget, and all owners share the runtime budget. Reclamation preserves log/status records, clears the file reference and sets artifact_reclaimed; old artifact handles stop resolving. Owner authorization is unchanged. Pre-quota persisted artifacts also count.
All record mutations and deletion share the reservation owner's serialization, so concurrent cursor/status updates cannot restore a reclaimed path or erase a reservation. A store-only restart preserves surviving registered captures. New capture reservations must be persisted and synced before launch; unavailable accounting storage or an unmeasurable legacy artifact fails admission instead of treating unknown disk usage as zero. Existing captures keep their charged reservation if later persistence fails.
A write failure closes the output device once, preserves the bounded tail and real execution status, and exposes only a fixed error classification. The closed file is checked for a partially written prefix so counters remain truthful. If its size cannot be checked, artifact_size_unknown is true, artifact_discarded_bytes is nil, and the full reservation remains charged. Failed file removal preserves its record/reference and storage charge; successful explicit cleanup/TTL cleanup releases them. TTL cleanup may remove an expired completed capture while its finalized session still lingers for final polling: its output device is already closed. A live capture that is not finalized remains charged even if cancellation/timeout has classified its record as terminal. No repeated writes are attempted after failure or after the capture limit is reached.
Memory
The [memory] section controls per-turn automatic recall. Its auto_recall setting is a boolean that defaults to true; set it to false to disable the bounded Relevant memory block attached to user messages. Automatic recall also stays off when [features] session_search = "off".
[memory]
auto_recall = true
write_approval = false # require human approval for remember mutations when truememory.write_approval gates every remember add, replacement, and deletion across user, memory, and project targets. Agent skill authoring has its own switch:
[skills]
write_approval = false # require human approval for skill_manage mutations when trueBoth switches default to false and use normal global/project config precedence. Enabled writes use the existing execution approval UI with bounded, escaped, credential-redacted target/action/content previews and a digest binding the full mutation to its owner/workspace. skill_manage report remains read-only. Approval never bypasses existing content screening, audit, pinning or isolation. Denial leaves the target unchanged; an unavailable/expired approval also denies. Prompts expire after 60 seconds unless the trusted runtime supplies a timeout. Background learning reviews (including draft creation), cron runs, and channels without approval controls fail closed without consuming saved grants. Interactive native sessions and Telegram/Discord use their existing approval flow. These switches gate agent tool writes, not explicit operator memory/skill services.
Feature Flags
Feature flags gate in-progress behaviour changes so they can be shipped incrementally without ad-hoc environment variables.
[features]
session_search = "default-on" # durable memory ingest and search (default)
routing_feedback = "default-on" # task fingerprinting + routing feedback (default)
skill_synthesis_drafts = "default-on" # skill synthesis draft pipeline (default)session_search defaults to "default-on": durable memory ingest and search are active on a stock install (retention 30 days, max 500 documents per scope; runs whose summaries look like they contain a secret are dropped entirely, not redacted). Set it to "off" to disable. The learning-loop flags (routing_feedback, skill_synthesis_drafts) also default to "default-on".
Valid rollout states:
| State | Meaning |
|---|---|
"off" | Feature fully disabled (kill-switch). |
"opt-in" | Legacy pre-promotion state; behaves like "off" unless the caller explicitly opts in. |
"default-on" | Enabled unless explicitly disabled. |
Each flag can be overridden via an environment variable using the pattern LEMON_FEATURE_<FLAG_NAME> (SCREAMING_SNAKE_CASE):
LEMON_FEATURE_SESSION_SEARCH=off # kill switch for durable memory
LEMON_FEATURE_ROUTING_FEEDBACK=default-onConfig validation fails cleanly if a flag is set to an unrecognised state.
Canonical Sections
Use only these top-level sections:
defaultsruntimemodel_routingbudgetsfeaturessecrets.sources.<source_id>profiles.<agent_id>providers.<name>gatewaytuiloggingautomationcron(failure-alert cooldown only; see Cron failure alerts)memoryandskills(see Memory)
A deprecated [agent] table still loads, with a warning in the log, lemon doctor and lemon config validate. It is read exactly as if it had been moved:
[agent] key | Read as |
|---|---|
default_provider, default_model, default_thinking_level | [defaults] provider, model, thinking_level (an existing [defaults] key wins) |
theme | [tui] theme |
anything else, including [agent.<sub>] tables | [runtime] / [runtime.<sub>] (an existing [runtime] key wins) |
Malformed TOML is a load error, not an empty configuration; missing files still use defaults. lemon config validate lists both checked files on success and failure. Fix syntax errors in the named file, then run lemon config validate --verbose. For removed settings, run lemon config migrate. Doctor records invalid configuration as failing checks and continues with the remaining diagnostics.
Normal configuration loads, including lemon config show and mix lemon.config show, also validate and reject invalid resolved values rather than quietly running with them. LemonCore.Config.Modular.load(validate: false) is an explicit programmatic opt-out for inspecting unresolved validation issues; removed settings and malformed TOML are always rejected. Validation messages name the input files and expected TOML types or allowed choices, never the supplied value. Resolved errors list both files because environment and project overrides can combine with global settings.
Both show commands use the same redacted summary: false and 0 are values; only absent values display (not set). Fields overridden by declared environment variables are marked (from LEMON_...) (or the active alias), without printing environment secrets.
Use [tui] theme or LEMON_TUI_THEME for appearance; this is the only runtime theme setting. Lemon runs agents natively rather than through vendor CLI engines.
The example leaves Telegram credentials and project/chat bindings commented out; enable only real bindings and use bot_token_secret instead of writing a token into TOML. XMTP's wallet_address, wallet_key, and private_key support whole-value ${NAME} environment references, resolved before the bridge connects. Unset references are omitted; use wallet_key_secret to read an encrypted secret.
lemon config migrate rewrites the global config and the current project's config this way, and lemon setup does the same when it finds an existing file. Both keep comments and unrelated tables, write the original to config.toml.bak-<UTC timestamp> first, and leave the file untouched when the rewrite cannot be verified (for example multi-line values inside [agent]).
These sections fail validation and runtime loading:
[agents.<id>]-> move to[profiles.<id>][tools.*]-> move to[runtime.tools.*]
lemon config migrate renames both headers as well.
External secret sources
External sources are read-only adapters integrated into LemonCore.Secrets.resolve/2; they are not another secret store. Resolution is ordered as follows:
- Lemon's encrypted store;
- enabled external sources ordered by
priority, then source id; - the same-name environment variable when
env_fallbackis enabled.
A successful source that does not contain the requested name continues to the next source. Any enabled source configuration, spawn, timeout, output, parse, or bootstrap failure stops resolution before the ordinary environment fallback. This fail-closed behavior prevents an unhealthy configured manager from being silently bypassed. env_fallback: false disables both the external and environment fallbacks; internal bootstrap reads use only the encrypted store and ordinary environment path so sources cannot recurse.
Every source requires the exact TOML boolean enabled = true. A quoted "true", unknown setting, unknown source type, relative executable path with a slash, shell command string, or out-of-range limit fails validation. Programs are started directly from an argv array, never through a shell. Each child gets only a small operating environment (HOME, platform path/temp/locale fields, and NO_COLOR) plus explicitly passed variables. Stdout and stderr are captured together under one byte limit and are never returned in errors, status, logs, or proof output.
Common source settings:
| Setting | Default | Contract |
|---|---|---|
type | required | onepassword, bitwarden, or command |
enabled | false | Only the exact boolean true enables execution |
priority | 100 | Integer 0..1000; lower runs first |
executable | provider default | Absolute path or bare executable name |
timeout_ms | 3000 | Integer 100..30000 |
max_output_bytes | 65536 | Integer 1..1048576, stdout and stderr combined |
cache_ttl_ms | 0 | Integer 0..300000; 0 disables caching |
The optional cache is bounded to 32 process-local entries and disappears on restart. It never persists source values to config or the encrypted store.
1Password
Map each Lemon secret name to an op:// reference. Lemon invokes op read --no-newline [--account ACCOUNT] -- REFERENCE separately for each mapping. If auth_secret is set, Lemon resolves that bootstrap credential only from its existing encrypted store or same-name environment fallback and gives it to the child as auth_env. Without auth_secret, an already authenticated local op session or the named ambient auth variable may be used.
[secrets.sources.onepassword]
type = "onepassword"
enabled = true
priority = 10
executable = "op"
timeout_ms = 3000
max_output_bytes = 65536
cache_ttl_ms = 0
account = "team"
auth_secret = "op_service_account_token"
auth_env = "OP_SERVICE_ACCOUNT_TOKEN"
refs = { anthropic_api_key = "op://Lemon/Anthropic/api-key", openai_api_key = "op://Lemon/OpenAI/api-key" }Bitwarden Secrets Manager
Bitwarden uses bws secret list PROJECT_ID --output json and accepts only a JSON list of unique non-empty {"key": ..., "value": ...} objects. Store the bootstrap access token in Lemon under access_token_secret, or provide the same-name environment variable. access_token_env controls the variable name passed to bws; an optional server_url must be credential-free HTTPS with no query or fragment.
[secrets.sources.bitwarden]
type = "bitwarden"
enabled = true
priority = 20
executable = "bws"
project_id = "team-project-id"
access_token_secret = "bws_access_token"
access_token_env = "BWS_ACCESS_TOKEN"
# server_url = "https://vault.example.com"Arbitrary command
Command sources accept only an argv array. Output is UTF-8 NAME=VALUE, one entry per line; blank lines and lines beginning with # are ignored. Names must be unique and values non-empty. pass_env copies only the listed ambient variables. secret_env maps a child environment name to an existing Lemon encrypted-store/same-name-environment secret; it never resolves another external source.
[secrets.sources.local_helper]
type = "command"
enabled = true
priority = 30
argv = ["/usr/local/bin/lemon-secret-helper", "export", "--profile", "prod"]
timeout_ms = 2000
max_output_bytes = 32768
cache_ttl_ms = 0
pass_env = ["HELPER_PROFILE"]
secret_env = { HELPER_TOKEN = "helper_bootstrap_token" }Inspect readiness without invoking any source, then perform a redaction-safe live test:
lemon secrets sources status --json
lemon secrets sources test --json
lemon secrets sources test local_helperThese commands reveal source id/type, readiness, provenance, counts, output byte count, duration, and stable error kinds only. They never reveal secret values. lemon secrets check reports a resolved credential only as present.
Dotenv Autoload
Lemon can auto-load a .env file at startup:
./bin/lemon-tui: loads<cwd>/.envwhere<cwd>is the agent working directory (--cwd, or current directory)../bin/lemon-gateway: loads.envfrom the directory where you launch the script.
By default, existing environment variables are preserved. .env values only fill missing variables.
OpenAI Codex (ChatGPT OAuth)
Lemon supports the Codex subscription provider as openai-codex (it uses the ChatGPT OAuth JWT, not OPENAI_API_KEY). The canonical config key is providers.openai-codex; providers.openai_codex is also accepted for backward compatibility by native Lemon/CodingAgent runs.
Primary setup paths use the installed lemon command, or the matching source wrapper from a checkout:
# Installed release
lemon setup
lemon model --provider openai-codex
# Source checkout
./bin/lemon setup
./bin/lemon model --provider openai-codex
# Contributor-level Mix alternatives
mix lemon.setup
mix lemon.onboard.codexWhat it does:
- Resolves Codex OAuth credentials via
LemonAi.Auth.OpenAICodexOAuth - Stores credentials in encrypted secrets
- Writes
providers.openai-codex.auth_source = "oauth"plusproviders.openai-codex.oauth_secret - Optionally updates
[defaults]provider/model - Uses an interactive arrow-key TUI for selection steps when running in a real terminal
- Listens on the localhost OAuth callback automatically and falls back to manual paste only if the callback cannot be captured
The onboarding flow opens the OpenAI auth URL directly and stores the returned OAuth credentials in Lemon secrets.
On a headless machine, or when port 1455 is busy, use the device-code sign-in instead: lemon model openai-codex --auth device prints https://auth.openai.com/codex/device and a one-time code, waits for you to approve it on any device, and stores the same refreshable login (it also works with --non-interactive).
To force a token explicitly, set:
OPENAI_CODEX_API_KEY(preferred)CHATGPT_TOKEN(fallback)
OpenAI-Compatible Endpoints
Lemon ships built-in presets for local inference servers and hosted open-model APIs that speak the OpenAI Chat Completions protocol. A preset provider accepts any model ID (ollama:qwen3:32b, lm_studio:qwen2.5-coder-7b, ...); model IDs may contain :. Model specs accept the underscore or dashed id (lm-studio:...); [providers.<id>] tables also accept omp spellings such as llama.cpp and ollama-cloud.
Local servers (no API key needed)
| Provider | Default base_url | Base-URL env | Optional key env |
|---|---|---|---|
ollama | http://127.0.0.1:11434/v1 | OLLAMA_BASE_URL, OLLAMA_HOST | OLLAMA_API_KEY |
lm_studio | http://127.0.0.1:1234/v1 | LM_STUDIO_BASE_URL | LM_STUDIO_API_KEY |
llama_cpp | http://127.0.0.1:8080/v1 | LLAMA_CPP_BASE_URL | LLAMA_CPP_API_KEY |
vllm | http://127.0.0.1:8000/v1 | — | VLLM_API_KEY |
litellm | http://localhost:4000/v1 | LITELLM_BASE_URL | LITELLM_API_KEY |
Requests go out without an Authorization header unless a key resolves (env variable, api_key/api_key_secret, or the default secret llm_<id>_api_key). base_url in config wins over the base-URL environment variables; environment values are normalized to a /v1 root (OLLAMA_HOST=gpu-box becomes http://gpu-box:11434/v1). A base_url written in config must include the OpenAI-compatible root (usually /v1).
To configure these interactively, run lemon setup provider --set-default and choose a local server, or pass --provider ollama (aliases such as --provider lm-studio also work). Setup offers an optional API key, stores any key in encrypted secrets, and writes auth = "none" when it is left blank. Model discovery requests GET {base_url}/models only after explicit consent; declining, an empty list, or a connection failure allows manual model entry. --model MODEL and non-interactive local setup never discover models. Existing configured base URLs are retained; otherwise setup uses the preset's base-URL environment variables or default.
The Custom OpenAI-compatible endpoint menu entry prompts for a new provider name, API base URL, and optional key. It writes api = "openai_completions" and either auth = "none" or an encrypted api_key_secret reference. No automatic runtime discovery setting is added. See the setup guide for commands and the separate live-verification behavior.
Readiness, lemon doctor, and offline setup verification accept these local presets without an API key. Custom keyless endpoints must explicitly set auth = "none"; a localhost URL alone does not waive a hosted provider's credential requirement.
[providers.ollama]
base_url = "http://gpu-box:11434/v1"
[defaults]
provider = "ollama"
model = "ollama:qwen3:32b"Hosted presets (API key required)
| Provider | base_url | Key env |
|---|---|---|
ollama_cloud | https://ollama.com/v1 | OLLAMA_CLOUD_API_KEY |
together | https://api.together.xyz/v1 | TOGETHER_API_KEY |
deepinfra | https://api.deepinfra.com/v1/openai | DEEPINFRA_API_KEY |
nvidia | https://integrate.api.nvidia.com/v1 | NVIDIA_API_KEY |
novita | https://api.novita.ai/openai/v1 | NOVITA_API_KEY |
baseten | https://inference.baseten.co/v1 | BASETEN_API_KEY |
venice | https://api.venice.ai/api/v1 | VENICE_API_KEY |
synthetic | https://api.synthetic.new/openai/v1 | SYNTHETIC_API_KEY |
nanogpt | https://nano-gpt.com/api/v1 | NANO_GPT_API_KEY |
zenmux | https://zenmux.ai/api/v1 | ZENMUX_API_KEY |
aimlapi | https://api.aimlapi.com/v1 | AIMLAPI_API_KEY |
siliconflow | https://api.siliconflow.com/v1 | SILICONFLOW_API_KEY |
siliconflow_cn | https://api.siliconflow.cn/v1 | SILICONFLOW_CN_API_KEY |
gmi_cloud | https://api.gmi-serving.com/v1 | GMI_API_KEY |
qianfan | https://qianfan.baidubce.com/v2 | QIANFAN_API_KEY |
aiand | https://api.aiand.com/v1 | AIAND_API_KEY |
wafer_serverless | https://pass.wafer.ai/v1 | WAFER_SERVERLESS_API_KEY |
kilo | https://api.kilo.ai/api/gateway | KILO_API_KEY |
Store a key with lemon secrets set llm_<provider>_api_key <key> or reference one with [providers.<provider>].api_key_secret. Lemon never sends OPENAI_API_KEY to a non-OpenAI provider.
Model discovery
Preset models carry conservative defaults (128k context, 16k output; Ollama 8k output) until discovered. lemon models --provider <preset> --discover lists what an endpoint serves (GET /models; Ollama uses /api/tags and /api/show for context length), using --base-url, the base-URL environment variable, or the default, and the provider's key environment variable when set. This low-level CLI discovery does not read [providers] overrides or encrypted credentials; use the picker for config-aware discovery, or pass --base-url and the preset's environment key explicitly. The TUI /model picker (daemon models.list) includes discovered models for hosted presets with a credential and for local servers that are configured in [providers.<id>] or through their base-URL environment variable; listings are cached for five minutes and an unreachable server contributes no models. Discovery caches are scoped to the endpoint, wire API, credential, headers and model defaults; one account's listing is not reused for another account or endpoint. Cache identifiers hash auth material. Registry-only preset lookup uses the current default endpoint and environment credential. The picker and native model selectors (including configured defaults, qualified selectors and provider-native colon IDs) instead use the full effective provider settings: the same endpoint, headers and first healthy selected-pool/default credential as requests. This also applies to dynamic presets with no explicit models list. Native lookup is cache-only; a scope without discovered metadata uses conservative preset context, output and capability defaults rather than another endpoint's or principal's cached values. An explicit preset provider retains native colon IDs (for example, Ollama's qwen:7b) even on a cold cache or after credential rotation; a registered provider prefix alone does not change that identity. Explicit model structs retain their caller-supplied metadata. Discovery refuses redirects and URLs with embedded credentials, queries or fragments; use headers for endpoint auth.
Any other OpenAI-compatible endpoint can still use the openai provider with a custom base_url. Hosted provider ids with bundled catalogs (opencode, openrouter, zai, minimax, kimi, xai, mistral, groq, deepseek, qwen, ...) take the same [providers.<id>] table with api_key_secret and optional base_url.
Custom Providers and Models
A [providers.<id>] table whose id is not a built-in provider defines a custom endpoint (the equivalent of omp's models.yml providers). Its models are selected as <id>:<model> or <id>/<model> in default_model, /model, model_roles, task/compaction selectors and provider_routing.fallback_providers, and they appear in the TUI /model picker once the provider has a credential.
Qualified [defaults] model selectors resolve their provider before default provider routing when the provider was inherited from a lower-priority config file, an environment model-only override, or the built-in fallback. For example, a project model of anthropic:claude-sonnet-5 overrides an inherited Ollama provider. A provider explicitly set in the same or a higher-priority source instead keeps the model ID native: provider = "ollama" with model = "qwen:7b" or even model = "anthropic:claude-sonnet-5" selects that exact Ollama model ID, regardless of discovery cache contents. Unknown prefixes remain native IDs.
[providers.my-gateway]
api = "openai_completions" # openai_completions | openai_responses |
# anthropic_messages | google_generative_ai
# (omp dashed spellings are accepted too)
base_url = "https://llm.internal/v1"
api_key_env = "MY_GATEWAY_KEY" # or api_key_secret / api_key
# auth = "none" # keyless endpoint: no Authorization header
headers = { "X-Team" = "platform" }
# discovery = "openai" # list models from {base_url}/models ("ollama": /api/tags)
[[providers.my-gateway.models]]
id = "qwen3-coder"
name = "Qwen3 Coder" # default: id
context_window = 131072 # default: 128000
max_tokens = 16384 # default: 16384
reasoning = false # default: false
input = ["text"] # "text" and/or "image"; default ["text"]
# cost = { input = 0.5, output = 1.5, cache_read = 0, cache_write = 0 } # USD per 1M tokens
# headers = { "X-Model" = "qwen" } # override provider headers per model- Credentials resolve from
api_key_env, thenapi_key, thenapi_key_secret; a custom provider never receives another provider's key (noOPENAI_API_KEYfallback). Withauth = "none"the provider is ready without a key. OpenAI-, Anthropic- and Google-compatible requests omit their generated credential header when no key resolves; configured headers still apply. Google-compatible endpoints use their own provider's options, not the built-in Google account's key or URL. - Header names are case-insensitive. Requests apply provider
headersas defaults, then per-modelheaders, then explicitly supplied caller stream headers. For example, providerX-Tenant = "A"and modelx-tenant = "B"send onex-tenant: B; a caller'sX-TENANT = "C"sends onex-tenant: C. This precedence applies to all four supported compatible wire APIs. Provider-required protocol rules still apply (for example, custom Anthropic beta tokens extend rather than remove the required beta tokens). apiandbase_urlare required. A provider withdiscoveryor without amodelslist accepts any model id (unlisted ids use the defaults above);discoveryalso lists the endpoint's models in the picker, cached by the shared model cache. Usediscovery = { type = "openai", timeout_ms = 5000 }to bound the listing request. Discovery needsapi = "openai_completions"or"openai_responses"; withdiscovery = "ollama",base_urlmay be the server root (http://127.0.0.1:11434) and requests go to its/v1API. Wire roots are protocol-specific: OpenAI APIs usually use/v1, Anthropic uses an origin/prefix to which/v1/messagesis appended, and Google uses a root such as/v1betato which/models/<id>:streamGenerateContentis appended. Picker discovery and subsequent model resolution use the same first healthy credential from the selected routing pool as native requests, followed by the configured default credential candidate. An unresolved pool reference does not authorize another pool or a built-in provider's ambient key. Cache listings and model metadata are isolated by the actual resolved credential (hashed, never stored in cache metadata), endpoint, wire API and headers.- On a built-in provider (
anthropic,openai, ...),headersare sent with every request and[[providers.<id>.models]]entries add catalog models or replace the catalog entry with the same id, inheriting the provider's wire API and base URL. lemon config validatereports unknownapi/auth/discoveryvalues, a missingbase_urlwhenapiordiscoveryis set, models withoutid, and invalid model limits, inputs and prices.- Not supported (omp-only): per-model
compatoverrides,modelOverrides,!commandsecrets, andproxy/litellmdiscovery.
Provider Onboarding (CLI)
For an installed release, start provider onboarding with lemon model. From a source checkout, use the matching ./bin/lemon model wrapper. The direct Mix tasks below remain the contributor interface.
# Installed release
lemon model
lemon model --provider anthropic
lemon model --provider openai-codex
lemon model --provider gemini
lemon model --provider zai
lemon model --provider minimax
# Source checkout
./bin/lemon model
./bin/lemon model --provider anthropic
./bin/lemon model --provider openai-codex
./bin/lemon model --provider gemini
./bin/lemon model --provider zai
./bin/lemon model --provider minimax
# Contributor-level Mix alternatives
mix lemon.onboard
mix lemon.onboard anthropic
mix lemon.onboard codex
mix lemon.onboard gemini
mix lemon.onboard zai
mix lemon.onboard minimax
mix lemon.onboard.antigravity
mix lemon.onboard.copilotlemon setup is the idempotent first-run journey: it derives config, secrets, and provider readiness; creates missing config and a secrets master key without replacing existing state; skips a provider that is already usable; and checks a new provider configuration before it reports setup complete. Setup always runs offline checks and normally runs a live provider check. Use lemon setup --skip-verify only to defer that live check when offline.
The focused lemon model command onboards one provider. It requires a usable encrypted secrets store; use lemon setup when a fresh machine still needs the config/secrets bootstrap and readiness verification.
Provider onboarding flows:
- Let you choose a provider when none is passed
- Use an interactive arrow-key TUI for provider/auth/model selection when a TTY is available
- Run provider OAuth flow by default when supported, or prompt for an API key/token otherwise
- Capture localhost OAuth callbacks automatically when the provider redirect URI is local
- Store credentials in encrypted secrets with provider metadata
- Write the relevant
providers.<provider>config keys - Support
--set-default,--model, and--config-path
API-key-only providers in the catalog are offered too: google (Gemini API), groq, cerebras, deepseek, mistral, huggingface, qwen (DashScope), vercel_ai_gateway, opencode, opencode_go, minimax_cn, fireworks, zai, kimi and minimax, plus every hosted OpenAI-compatible preset (together, deepinfra, nvidia, …). The pasted key is stored as llm_<provider>_api_key and referenced through [providers.<provider>].api_key_secret. For presets, --set-default lists the models the endpoint reports for that key, and --model accepts any model id.
Provider readiness is visible through the source and packaged lemon providers status command. It uses the same LemonAgent.ModelRuntime.Credentials credential resolver as model execution, so env keys, encrypted secret references, OAuth/default-secret paths, and provider-specific credential shapes are checked the same way runtime calls check them. The response reports booleans such as credentialReady, apiKeyConfigured, apiKeySecretConfigured, oauthSecretConfigured, baseUrlConfigured, and envConfigured; it does not return raw API keys, secret names, base URLs, or env var names.
Fallbacks and credential-pool references can be edited through the same packaged/source command boundary:
# Installed release (use ./bin/lemon from a source checkout)
lemon providers fallback add zai
lemon providers pool set burst --provider openai --provider zai \
--strategy round_robin --activate
lemon providers pool credential add burst openai secret:openai_backup
lemon providers status --jsonOnly explicit secret:NAME and env:NAME references are accepted for pool credentials; values remain owned by the encrypted secret store or process environment. The command preserves unrelated comments, validates the complete resulting TOML, and atomically replaces the selected global or project config. Mutations apply by default; use --dry-run to preview. Removing a fallback, deleting or updating an existing pool, and removing or clearing credential references require the exact confirmation value shown by the preview:
lemon providers fallback remove zai --dry-run --json
lemon providers fallback remove zai --confirm zaiTo keep several logins for one provider, sign in again under another secret name and add that name to a pool; OAuth logins referenced from a pool refresh like the default login:
lemon model anthropic --auth oauth --secret-name llm_anthropic_work
lemon providers pool credential add burst anthropic secret:llm_anthropic_workThe most recent onboarding selects its secret in the provider config; it does not delete other stored accounts. To inspect or delete a different stored account, use lemon model anthropic --status --secret-name llm_anthropic_work or --logout --secret-name llm_anthropic_work. Named logout leaves the active provider config unchanged unless that exact secret is the configured login. Pool references to a deleted secret remain in config but no longer resolve; remove them separately with lemon providers pool credential remove.
Memory-provider readiness is included in support-bundle memory_diagnostics.json. These surfaces expose provider ids, enabled state, source labels, scopes, timeout shape, and module load state without memory document contents, raw provider config, secret values, prompts, tool output, or provider error payloads.
Doctor support bundles include a core-owned provider_diagnostics.json snapshot for offline support. That snapshot reports provider setup shape, credential reference counts, ambient-provider booleans, and routing/pool/profile shape without depending on runtime provider modules. It intentionally omits raw API keys, secret names, raw base URLs, env var names, model prompts, and provider responses.
Provider route previews are controlled by runtime.provider_routing:
[runtime.provider_routing]
enabled = true
fallback_providers = ["zai", "anthropic"]
fallback_models = ["openai:gpt-4o-mini", "@slow"]
default_pool = "burst"
default_profile = "ops"
require_credentials = true
[runtime.provider_routing.credential_pools.burst]
providers = ["openai", "zai", "anthropic"]
strategy = "round_robin" # priority | round_robin
[runtime.provider_routing.profiles.ops]
fallback_providers = ["zai"]
credential_pool = "burst"
distribution = { openai = 70, zai = 20, anthropic = 10 }lemon providers status includes a redacted routingConfig block for effective fallback/pool configuration and a routing block with the requested provider/model, selected provider/model, fallback candidates, candidate readiness booleans, selected routing profile, selected credential pool, profile distribution weights, pool provider names, pool strategy, and credential-reference counts. Pool/profile names and provider names are visible; raw API keys, secret names, base URLs, and env var names are not.
Coding-agent default model resolution consumes the same routing policy conservatively: if the default provider is not credential-ready and a configured fallback/profile/pool provider is credential-ready with the same model id in LemonAi.Models, Lemon selects that fallback before starting the supervised agent loop. Pools default to priority order; strategy = "round_robin" rotates the pool's starting provider through LemonAgent.ModelRuntime.ProviderPoolRotator, a supervised BEAM process. Explicit user model specs are never rewritten at resolution time.
The supervised coding-agent loop also wraps streams for default-model and explicit-model sessions. fallback_models selects an ordered chain of concrete provider:model selectors or @role aliases; alternatives on the same provider are allowed. Existing fallback_providers routes remain useful when providers host the same model id. Each candidate resolves its own provider options and credentials; credentials from one provider are never reused for another.
The wrapper preserves explicit authentication-refusal results instead of converting them into unset API keys. In particular, missing or invalid Codex auth_source cannot enable provider-level fallback to ambient tokens.
Idle and live model switches bind request options to their actual owning model before classification or completion. Selected-login refusal stays authoritative after a switch; switching to a keyless provider clears the previous authority. Fallback sanitation uses the current invocation's options owner, not the session's startup provider. Same-provider explicit headers and pool precedence are preserved. Same-provider rebinding retains the original caller header layer separately: provider defaults remain below the destination model's headers, and only genuine caller overrides take precedence. Headers derived from the source model never become caller overrides on a later request.
Before output is committed, provider failures can move to the next model and rate/usage limits can rotate through configured credential-pool entries. Authentication failures refresh the same OAuth credential once on HTTP 401, then rotate if refresh fails or the refreshed token is rejected. Static keys and environment references cannot refresh. Provider retry hints are retained for credential cooldowns. The model/credential that commits is pinned for subsequent turns in that session, preserving provider caches.
When an untried healthy credential or fallback candidate is available, a rate limit returns immediately to routing without sleeping or retrying the same credential. With no healthy alternate, the provider's bounded retry policy is unchanged. Credentials with exhausted account quota or credits retain the rate-limit failover category but cool down for 15 minutes, doubling on repeated failures up to one hour; longer provider retry-after hints are still honored.
Missing local API keys are credential failures, not provider outages. They remain non-retryable against the same credential and do not open the provider-wide circuit breaker or block another session with valid credentials.
For generic OAuth providers, auth_source = "oauth" makes the selected stored login authoritative for both initial resolution and 401 refresh: oauth_secret, then api_key_secret, then the provider's default secret name. A missing selected login fails closed rather than using an ambient environment key, inline key, or another account's default login. Other auth modes keep their existing environment-first API-key precedence.
Malformed requests (for example HTTP 400) are terminal, including failures before a stream starts. Context overflow reaches the existing once-per-turn compaction recovery instead of replaying an unchanged prompt on another provider.
No automatic retry, credential rotation, or model fallback occurs after text, thinking, or a tool-call event is committed, including whitespace text and content carried only by a terminal message. The failure is surfaced with its partial output, without executing unfinished tool calls. Caller cancellation stays cancellation. This rule also applies to [runtime.retry] transient retries.
For a manual source-checkout smoke run without live credentials, run MIX_ENV=test mix run --no-start scripts/provider_fallback_smoke.exs. It starts an isolated native session and loopback fixture endpoint, rejects the primary model with HTTP 503, and verifies the configured alternate model answers and the session is persisted. The smoke script is not part of the CI test lane.
MIX_ENV=test mix run --no-start scripts/live_provider_ownership_smoke.exs exercises public native Session and Agent model switches, missing-selected-login refusal (including plan-role selection), successful keyless transitions, switch-then-fallback header isolation and same-provider rebinding with no caller headers and with genuine caller overrides, using fixture credentials and loopback HTTP only. Its refusal and credential-leak checks scan complete captured events and logs, including large tool-schema snapshots. The intentional fallback failure uses fixture-owned provider health processes, so repeated runs neither inherit nor contaminate the real provider's circuit breaker or request-rate bucket.
Google Gemini CLI onboarding (lemon model --provider gemini, or ./bin/lemon model --provider gemini from a checkout) resolves OAuth credentials via LemonAi.Auth.GoogleGeminiCliOAuth, stores the encrypted payload in providers.google_gemini_cli.api_key_secret, writes providers.google_gemini_cli.auth_source = "oauth", and can take --project-id <gcp-project-id> to force a specific Code Assist project. The contributor task is mix lemon.onboard gemini. At runtime, Lemon re-resolves the active Gemini project from providers.google_gemini_cli.project_id, providers.google_gemini_cli.project_secret, LEMON_GEMINI_PROJECT_ID, GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_PROJECT_ID, or GCLOUD_PROJECT, with those values overriding the projectId stored inside the OAuth payload.
The onboarding alias gemini maps to the runtime provider google_gemini_cli. This is distinct from the AI Studio provider google, which expects a separate API key such as GOOGLE_GENERATIVE_AI_API_KEY.
Other subscription and browser sign-ins offered by lemon model <provider>:
| Provider | Sign-in | Stored as |
|---|---|---|
openrouter | Browser PKCE sign-in (callback on http://localhost:54549/callback, or paste the redirect URL); a pasted sk-or- key is checked and accepted | A durable OpenRouter API key in providers.openrouter.api_key_secret |
xai | SuperGrok / X Premium+ device code (--auth oauth); API key remains the default | Refreshable login in providers.xai.api_key_secret, auth_source = "oauth" |
kimi_coding (kimi-code) | Kimi Code device code (default) | Refreshable login in providers.kimi_coding.api_key_secret, auth_source = "oauth" |
zai | GLM Coding Plan sign-in (--auth oauth): paste the full final zcode://zai-auth/callback?... redirect (or code#state, not a bare code); Lemon finds or creates an API key named lemon | A durable Z.AI API key in providers.zai.api_key_secret |
Refreshable logins renew shortly before expiry and when a provider rejects the token. While auth_source = "oauth" is set, the provider's API-key environment variables (for example XAI_API_KEY or KIMI_API_KEY) do not override the stored login. lemon model <provider> --status reports the stored login or key without printing it; --logout deletes it and its config references but does not revoke it at the provider. KIMI_CODE_OAUTH_HOST overrides the Kimi auth host; the ZAI_OAUTH_* variables override the Z.AI endpoints for allowlist changes.
Token-grant requests do not follow redirects. Device-code deadlines include time spent waiting for browser instructions and HTTP requests. Refresh and persistence are serialized per stored login; waiting callers reload the same secret before reusing or rotating it. A removed/replaced login is not recreated from a stale reader, and expired or explicitly rejected tokens are never returned as successful authentication after a failed refresh. Endpoint/HTTP overrides exposed by library APIs are trusted caller options intended for testing and controlled integrations, not untrusted remote input.
Antigravity OAuth client credentials resolve from Lemon secrets first:
google_antigravity_oauth_client_idgoogle_antigravity_oauth_client_secret
Environment variables are supported as fallback:
GOOGLE_ANTIGRAVITY_OAUTH_CLIENT_IDGOOGLE_ANTIGRAVITY_OAUTH_CLIENT_SECRET
Common non-interactive usage:
# Installed release
lemon model --provider antigravity --token <token> --set-default --model gemini-3-pro-high
lemon model --provider gemini --project-id your-gcp-project
lemon model --provider gemini --token <token> --set-default --model gemini-2.5-pro
lemon model --provider openai-codex --token <token> --set-default --model gpt-5.2
lemon model --provider github-copilot --enterprise-domain company.ghe.com
lemon model --provider github-copilot --skip-enable-models
lemon model --provider github-copilot --token <token> --set-default --model gpt-5
lemon model --provider zai --token <token> --set-default --model glm-5
lemon model --provider minimax --token <token> --set-default --model MiniMax-M2.7
# Source checkout
./bin/lemon model --provider antigravity --token <token> --set-default --model gemini-3-pro-high
./bin/lemon model --provider gemini --project-id your-gcp-project
./bin/lemon model --provider gemini --token <token> --set-default --model gemini-2.5-pro
./bin/lemon model --provider openai-codex --token <token> --set-default --model gpt-5.2
./bin/lemon model --provider github-copilot --enterprise-domain company.ghe.com
./bin/lemon model --provider github-copilot --skip-enable-models
./bin/lemon model --provider github-copilot --token <token> --set-default --model gpt-5
./bin/lemon model --provider zai --token <token> --set-default --model glm-5
./bin/lemon model --provider minimax --token <token> --set-default --model MiniMax-M2.7
# Contributor-level Mix alternatives
mix lemon.onboard.antigravity --token <token> --set-default --model gemini-3-pro-high
mix lemon.onboard.gemini --project-id your-gcp-project
mix lemon.onboard.codex --token <token> --set-default --model gpt-5.2
mix lemon.onboard.copilot --enterprise-domain company.ghe.com
mix lemon.onboard zai --token <token> --set-default --model glm-5
mix lemon.onboard minimax --token <token> --set-default --model MiniMax-M2.7Anthropic provider auth supports either API keys or a Claude Pro/Max subscription login.
API key flow:
lemon model anthropic --auth api_key
# or store the key directly:
mix lemon.secrets.set llm_anthropic_api_key_raw <token>Subscription flow (browser sign-in, stored encrypted and refreshed automatically):
lemon model anthropic --auth oauth[providers.anthropic]
auth_source = "oauth"
oauth_secret = "llm_anthropic_api_key" # written by sign-in; this is the default nameauth_source decides precedence: "oauth" uses only the subscription login and ignores ANTHROPIC_API_KEY; "api_key" or no value uses the API key. A stored login whose refresh is rejected fails closed. When no Lemon login is stored, auth_source = "oauth" falls back to ambient Claude Code credentials from ~/.claude/.credentials.json or CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_TOKEN, preferring refreshable Claude Code credentials over a static env token. See Claude subscription sign-in for --status, --logout, and refresh behavior.
Tool Approval Modes
[runtime.tools]
approval_mode = "write" # ask | write | yolo| Mode | Approvals required |
|---|---|
ask | All non-read tools (write and exec) |
write | Executable tools (exec) only |
yolo | No tier-based approvals |
Leaving the setting unset preserves existing behavior, including configured per-tool and source-specific approval requirements. Reads/search/web fetches are read; built-in file/state mutations are write; shell/process control, eval/debug, browser evaluation, MCP/WASM/extension and unknown tools are exec. LSP navigation and edit previews are read; applying edits, raw requests and server reloads are exec.
Config supplies only the default. A canonical tool policy can set approval_mode = "ask", "write" or "yolo" in [profiles.<id>.tool_policy] or through the existing PolicyStore session/runtime policy. Explicit policy modes win over the config default. Policy intersections retain the stricter mode, so delegated work cannot weaken parent approval gates. The existing approvals map (always/never) overrides tier defaults in every mode; require_approval, parent floors, and explicitly configured MCP approval requirements remain mandatory. An on_tool_call hook's revised arguments are used for approval resolution and the approval preview, not the original input.
Bash Destructive Git Guard
[runtime.tools.bash]
git_guard = true # default; false disables destructive Git checksThe boolean guard refuses Git commands that discard uncommitted work, delete stashes/unmerged branches, or force shared history without a lease. Checks run on expanded commands before every bash launch mode; refusals identify the operation, risk and disable setting. reset --soft/--merge/--keep, staged-only restore, push --force-with-lease and ordinary branch checkout remain allowed. See shell tools for blocked forms.
Effective cwd inside a task isolation worktree is exempt only with a matching task-store workspace record and Git worktree backlinks. Directory names alone do not qualify. Git -C and the bash cwd option are respected; uncertain cwd, repository/environment redirection or unavailable ownership records disable the exemption. This guard is not a security sandbox for dynamic shell evaluation.
Bash Dedicated-Tool Routing
Opt in to shell interception in user or project .lemon/config.toml:
[runtime.tools.bash.interceptor]
enabled = true # default false
# Optional replacement for the complete default rule list:
# rules = [{pattern = '^cat\s+', tool = 'read', message = 'Read files with read.'}]Rules are {pattern, tool, message} tables with valid regular expressions and non-empty tool/message strings. Defaults route cat/head/tail/less/more to read, grep/rg (also ripgrep/ag/ack) to grep, find/fd/locate to find, ls to ls, and sed/perl in-place edits to edit. A rule only blocks when its target is in the session's filtered tool set. Pipeline consumers such as git log | head remain allowed. Checks scan simple commands, stripping assignments and common execution prefixes, including commands inside subshells and substitutions; quoted data does not become a command. They are advisory routing checks, not a shell sandbox. Checks run after internal URL expansion and before foreground, background or named-service launch. Refusals return an error tool result and configuration disable guidance. See shell tools.
Anchored Edit Visibility
Seen-line enforcement is on by default (strict). Disable it in user or project .lemon/config.toml:
[runtime.tools.edit]
enforce_seen_lines = false # default trueEvery replaced/deleted line and insertion anchor must have been displayed by a numbered read of that snapshot in the current session. Omitted summary bodies and window gaps do not count; refusals provide a range-read hint without writing. Historical recovery checks visibility before translating unchanged anchors. Plain old_text/new_text replacements are unaffected. The setting must be a boolean; the default (true) refuses anchored edits to unread lines. Tool hosts can explicitly override it with the :enforce_seen_lines option. See file tools for visibility and snapshot-retention details.
Language Servers
Configure [runtime.tools.lsp.servers.<id>] in user or project config. The effective catalog merges built-ins, user values, then project values. Known ids override fields; custom ids require command and a non-empty extensions list (file_types is an alias).
[runtime.tools.lsp]
idle_timeout_ms = 30000 # grace after the last owner releases; 0 stops immediately
[runtime.tools.lsp.servers.clangd]
command = "/opt/llvm/bin/clangd"
args = ["--background-index"]
root_markers = ["compile_commands.json", ".git"]
extensions = [".c", ".h"]
language_id = "c"
initialization_options = { clangdFileStatus = true }
env = { CLANGD_FLAGS = "" }
disabled = falsecommand and language_id are non-empty strings; args, root_markers, extensions/file_types are string arrays; env is a string-to-string table; initialization_options is a table; disabled is boolean. Lists replace inherited values; nested tables merge. Command environment variables take priority over TOML. Disabled servers are excluded from all catalog lookups. See LSP tools for custom servers and scope.
Web Tools (websearch / webfetch)
Lemon includes web tools under runtime.tools.web. For full setup and troubleshooting, see:
[runtime.tools.web.search]
enabled = true
provider = "brave" # brave | exa | perplexity | tavily | kagi | jina | duckduckgo | searxng | extension id
max_results = 5
timeout_seconds = 30
cache_ttl_minutes = 15
[runtime.tools.web.search.failover]
enabled = true
provider = "perplexity"
[runtime.tools.web.search.perplexity]
# Optional if PERPLEXITY_API_KEY / OPENROUTER_API_KEY is set.
api_key = "<perplexity-api-key>"
base_url = "https://api.perplexity.ai"
model = "perplexity/sonar-pro"
[runtime.tools.web.search.providers.searxng]
# Required only when selecting the SearXNG provider.
base_url = "https://search.example.com"
# Optional bearer token or Lemon secret name.
api_key_secret = "SEARXNG_API_KEY"
[runtime.tools.web.search.providers.exa]
# Optional if EXA_API_KEY is set.
api_key = "<exa-api-key>"
# tavily, kagi and jina take the same shape (api_key or api_key_secret);
# TAVILY_API_KEY / KAGI_API_KEY / JINA_API_KEY are used when unset.
[runtime.tools.web.search.providers.tavily]
api_key_secret = "TAVILY_API_KEY"
[runtime.tools.web.fetch]
enabled = true
max_chars = 50000
timeout_seconds = 30
cache_ttl_minutes = 15
max_redirects = 3
max_body_bytes = 5242880 # Streaming response limit (5 MiB); body_too_large is terminal.
readability = true
allow_private_network = false
allowed_hostnames = []
[runtime.tools.web.fetch.firecrawl]
# Optional if FIRECRAWL_API_KEY is set.
enabled = true
api_key = "fc-..."
base_url = "https://api.firecrawl.dev"
only_main_content = true
max_age_ms = 172800000
timeout_seconds = 60
[runtime.tools.web.cache]
persistent = true
path = "~/.lemon/cache/web_tools"
max_entries = 100Browser backends and existing Chrome
Browser provider selection is shared by tool availability, doctor, and control-plane status. See Choosing a browser provider for the auto order, fail-closed behavior, LCU prerequisites and connector rollback.
[runtime.tools.browser]
provider = "auto" # lcu, relay, local, cdp, browserbase, browser_use,
# firecrawl, camofox, controller, hybridLEMON_BROWSER_PROVIDER takes precedence over TOML. auto checks LCU, then the authenticated relay, then managed local Chrome; it never installs a connector. Explicit providers never fall back to another browser identity. Provider credentials remain in the secret store/environment, not this selector. The following environment variables configure individual backends:
| Variable | Default | Purpose |
|---|---|---|
LEMON_BROWSER_PROVIDER | auto | Unified provider id (see above) |
LEMON_BROWSER_DRIVER_PATH | auto | Browser Node driver path |
LEMON_BROWSER_CDP_ENDPOINT | none | HTTP discovery or direct ws/wss CDP endpoint |
LEMON_BROWSER_ATTACH_ONLY | false | Never launch a replacement browser |
LEMON_BROWSER_CDP_PORT | 18800 | Managed local Chrome CDP port |
LEMON_BROWSER_RELAY_PORT | 9224 | Loopback MV3 relay port |
LEMON_BROWSER_RELAY_TOKEN | none | Required relay shared secret |
LEMON_BROWSER_BACKEND | local | local, controller, hybrid, browserbase, browser_use, firecrawl, or camofox |
LEMON_BROWSER_HYBRID_LOCAL_BACKEND | local | Local/private route used by hybrid |
LEMON_BROWSER_HYBRID_PUBLIC_BACKEND | none | Required public route used by hybrid |
BROWSERBASE_API_KEY / BROWSERBASE_PROJECT_ID | none | Browserbase hosted sessions |
BROWSER_USE_API_KEY | none | Browser Use Cloud hosted sessions |
CAMOFOX_URL / CAMOFOX_API_KEY | none | Camofox REST/Firefox server |
For the existing-Chrome relay, set LEMON_BROWSER_CDP_ENDPOINT to ws://127.0.0.1:<port>/cdp?token=<token> and set attach-only mode. Treat that endpoint as a secret: Lemon redacts common credential query parameters and reports only endpoint hashes in status surfaces. Backend/controller identity is explicit and fail-closed; an unavailable controller never falls back to a different browser profile. See docs/tools/web.md.
WASM Tools
WASM tools are disabled by default and run in a per-session Rust sidecar. See docs/tools/wasm.md for runtime behavior and troubleshooting.
[runtime.tools.wasm]
enabled = false
auto_build = true
runtime_path = ""
tool_paths = []
default_memory_limit = 10485760
default_timeout_ms = 60000
default_fuel_limit = 10000000
cache_compiled = true
cache_dir = ""
max_tool_invoke_depth = 4Computer Use
The explicit native computer-use switch defaults to off. Resolving browser provider lcu also starts the same runtime, including native app APIs protected by the existing approval policy. See docs/tools/computer-use.md for requirements and approvals. LEMON_COMPUTER_USE_* environment variables override these keys.
[runtime.tools.computer_use]
enabled = false
# lcu_root = "~/.local/share/lcu/current"
# node = "<lcu_root>/agent-tools/node/bin/node"
declined_origins = []Conversation checkpoints
[runtime.tools.checkpoint]
enabled = falseEnable the paired model tools checkpoint(goal) and rewind(report) to collapse exploratory conversation into a retained branch summary. This never restores files and needs no confirmation; operator /rewind is unchanged. The filesystem tool is named fs_checkpoint (no checkpoint alias). See checkpoint tools.
Semantic find
semantic_find maps a plain-language behavior description ("where a failed upload is retried") to ranked path:line locations. It is off by default and is separate from find (file-name globs) and grep (regex). The tool is not registered, and not shown to the model, unless it is enabled and a small model is explicitly configured.
[runtime.tools.semantic_find]
enabled = false # opt in with true
# model = "@smol" # optional; defaults to model_roles.tiny, then model_roles.smol
max_tokens = 8000 # per-call cap on prompt + completion tokens (512-200000)
[model_roles]
smol = "openai:gpt-4o-mini"- Model.
semantic_find.model(a concreteprovider:modelor a configured@role) wins; otherwise the explicitly configuredmodel_roles.tiny, thenmodel_roles.smol. The active session model is never used as a fallback: with no small model configured the tool stays unavailable. Role chains must resolve completely; missing roles never fall back to the main model. Only OpenAI (openai_completions/openai_responses) and Anthropic (anthropic_messages) APIs are currently audited for a provider-side output ceiling and byte-level prompt bound. Other provider/API pairs, includingopenai-codex, fail closed and leave the tool unavailable. - Cost. Every call that finds candidates makes one small-model request with no tools and at most
max_tokenstokens: the completion ceiling (a quarter of the cap, between 128 and 1024 tokens) is sent as the request's output limit, and the candidate list is trimmed using one token per UTF-8 byte plus 256 tokens for message framing (an upper bound, not an average token estimate), reserving the completion allowance before dispatch. A description with no keyword hits makes no model call. The cap bounds one call; there is no separate per-session limit, and these calls are not counted against the cumulative usage budgets. - Usage. The provider-reported tokens for each call are recorded in the usage ledger under the session's run and session key (provider/model of the small model), so they appear in
usage.cost, usage records and the usage page like any other model request. A price Lemon cannot attest stays unknown rather than becoming an estimate. The tool result'sdetails.usagerepeats the numbers. - How it searches. There is no embedding index or other persistent store. Each call scans the requested roots (skipping hidden, ignored, dependency/build, binary and oversized files, with file-count and byte caps), scores lines against the description's keywords, and lets the model only choose among the numbered lines it was sent, so it cannot invent a location. Repository text sent to the small model is untrusted data, and results are marked untrusted.
Tool intent
Tool schemas include an optional string i by default: a concise, 2–6 word present-participle phrase such as "Reading model role settings". Lemon strips this presentation-only field before argument validation/coercion and execution, uses it as the approval rationale, and retains it in tool-call start event arguments (ACP uses it as the tool-call title). Intent is not authorization and is never added to tool-call telemetry. Persisted assistant arguments are unchanged.
[runtime.tools]
intent_tracing = true # false omits the injected property from provider schemasDirect LemonAgent.Agent and CodingAgent.Session callers can override this with :intent_tracing; stateless loops use AgentLoopConfig.intent_tracing. Disabling schema injection does not let stray presentation fields reach tools. A tool whose schema already defines i is exempt: Lemon preserves its native parameter and does not treat it as intent. Closed union alternatives receive the optional property individually. Lemon's OpenAI providers explicitly use non-strict tool schemas, so i remains optional without changing required or additionalProperties.
Tool Disclosure
Every tool a session exposes sends its name, description and JSON schema with each model request. [runtime.tools.disclosure] can move tools behind two bridge tools: tool_search (look up a deferred tool and read its schema) and tool_invoke (call it by name). A bridged call runs the same policy, approval, checkpoint, cancellation and per-session mutation-lock path as a direct call, and tool_invoke never runs in parallel with other calls. Plan mode judges a bridged call by the tool it reaches.
[runtime.tools.disclosure]
enabled = true # false keeps every tool directly exposed
budget_tokens = 40000 # legacy profile: defer only above this estimate
catalog_tokens = 2000 # size of the deferred-tool list in tool_search's description
max_results = 5 # default tool_search result count
profile = "legacy" # "legacy" (default) or "coding" (opt-in experiment)
pinned = [] # tool names that always stay directly exposedlegacy(default): built-in tools stay exposed. The MCP, extension and WASM tools are deferred only when the estimated schema cost of the whole catalog exceedsbudget_tokens; below it the tool list is unchanged.coding: an experiment for coding sessions. Only the coding core stays directly exposed —read,write,edit,patch,ls,find,grep,ast_grep,lsp_diagnostics,fs_checkpoint,bash,bash_output,kill_process,websearch,webfetch,read_skill,recall,todo,ask, enabledcheckpoint/rewind,task,wait,agent_messageandparent_question— pluspinnednames. Every other resolved tool, including browser, media, automation and other optional built-ins, is deferred whenever there is one to defer;budget_tokensis not consulted. Whether this improves cost, latency or task success is not established, so it is not the default.
Tools the session policy denies are never searchable or invokable. Tools passed as extra_tools always stay exposed, and sessions started with an explicit tools list skip disclosure. Names tool_search and tool_invoke are reserved; a real tool using either name is dropped while disclosure is active.
WASM host aliases resolve from this same frozen, policy-authorized runtime catalog, not only the schemas currently shown to the model. A permitted optional built-in such as browser_tabs therefore remains a host capability under either profile. Host calls retain the catalog's wrapped implementation and the current plan-mode gate; reload replaces that inventory so removed authority is not kept alive by an old host lookup. Neither WASM tools nor the disclosure bridge tools can be host targets, preventing recursive calls through the deferred catalog.
Target closures are wrapped before disclosure partitions the catalog. Direct, bridged and host execution restore secret arguments before target hooks and approval checks. Plan-mode bridge and host writes reuse the session's restricted write/edit wrappers, including the final directory check after hook replacement; schema deferral does not create another execution authority.
The tool list is computed at session start and on extension reload, never per turn. extensions_status reports the active profile, the exposed and deferred tool names, estimated schema tokens before and after disclosure, and a catalog_digest: a SHA-256 over the exposed schemas, the deferred tools, their sources and the session's tool policy. Matching digests identify matching schemas, source metadata and policy; they do not attest to external tool state or outcome. An authority change recomputes the catalog at the next run's session startup/resume boundary, or when explicitly supplied to a lifecycle reload, and changes its identity even if the visible schemas remain identical. Denied tools are removed from the rebuilt bridge, not merely hidden from its search results. The [:coding_agent, :tool_disclosure, :applied] telemetry event also carries the profile and safe catalog digest, with before/after schema estimates and tool counts as measurements, so isolated trial collectors need not log raw schemas. Diagnostic event version 1 adds :search events with a matched count and success/error status, and :invoke_rejected events with a bounded rejection reason (unknown_tool, invalid_arguments or invalid_request). These events contain no query, tool name, arguments or error text. Pre-aborted calls do not count as discovery or incorrect-call failures.
For a controlled trial, scope the profile to the trial instead of the user's global config: put the section in the benchmark project's .lemon/config.toml (applies only to sessions in that project), set LEMON_TOOL_DISCLOSURE_PROFILE=coding on a separately launched runtime (it overrides file config for that process only), or start sessions programmatically with CodingAgent.Session.start_link(tool_disclosure: [profile: :coding]).
Sections
providers.<name>: API keys and base URLs per provider.defaults: global default provider, model, and thinking level for native execution.runtime: runtime behavior and tool settings.runtime.tools.web:websearch/webfetchproviders, guardrails, cache, and Firecrawl fallback.runtime.tools.wasm: WASM sidecar runtime controls and discovery paths.runtime.tools.disclosure: deferring tool schemas behindtool_search/tool_invoke(see Tool Disclosure).profiles.<agent_id>: assistant profiles (identity + defaults) used by gateway/control-plane.runtime.compaction: context compaction settings. Automatic compaction runs when the request exceeds the context window minusreserve_tokens. Optionalthreshold_percent(integer 1–100, percent of the window) orthreshold_tokens(positive integer; wins over the percent) lower that threshold; they never raise it above the reserve limit. Invalid values fail config validation./contextandsessions.contextreport the effective threshold.runtime.retry: session retry of transient provider failures (backoff + retry-after).runtime.max_tool_turns: optional per-prompt cap on tool-using turns (default unlimited).runtime.secret_obfuscation: opt-in placeholder obfuscation of secrets in provider-visible text (details).tui: terminal UI settings.gateway: Lemon gateway settings, includingqueue,telegram,discord,sms,voice,xmtp,projects, andbindings.logging: optional file logging configuration.automation: cron catch-up and run-history retention.cron: cron failure-alert cooldown settings.memory: per-turn automatic recall andrememberwrite approval;skills:write_approvalforskill_manage(Memory).budgets: cumulative usage budgets (Cumulative usage budgets).features: feature flags (Feature Flags).secrets.sources.<source_id>: external secret sources (External secret sources).gateway.a2a: A2A peer transport (A2A peers).
Automation
[automation] holds cron scheduler settings:
[automation]
catch_up_missed = true
run_retention_per_job = 100catch_up_missed(default:true) — restart runs one missed slot when it is within half the schedule's next-occurrence gap, clamped to 2 minutes–2 hours. Older slots are audited as skipped; expired one-shots are retired. Setfalseto skip all missed slots. Paused jobs remain paused.run_retention_per_job(default:100) — non-negative number of terminal runs retained per job by hourly cleanup. Active attempts and undispatched retries are exempt. This also applies to history of removed jobs.
Cron failure alerts
[cron]
failure_alert_cooldown_ms = 21600000 # 6 hoursfailure_alert_cooldown_ms is a non-negative integer, resolved and validated by the core automation configuration. It limits repeated failure alerts per job and normalized error class; the first failure and a changed error class alert immediately. 0 disables the cooldown. Incident state survives restarts. Recovery notices are separate from cooldown reminders. See the failure-alert workflow for routing, silent successes, and local doctor warnings.
Gateway core settings
Top-level [gateway] keys (defaults from LemonCore.Config.Gateway):
| Key | Default | Meaning |
|---|---|---|
max_concurrent_runs | 2 | Gateway-wide concurrent run limit (LEMON_GATEWAY_MAX_CONCURRENT_RUNS). |
default_cwd | unset | Fallback working directory for unbound chats (LEMON_GATEWAY_DEFAULT_CWD). |
auto_resume | false | Resume interrupted sessions on start. |
enable_telegram, enable_discord, enable_xmtp, enable_webhook, enable_a2a | false | Enable the named channel; enable_<id> is read per registered channel (and LEMON_GATEWAY_ENABLE_<ID> overrides it). |
require_engine_lock | true | Serialize runs per engine session (LEMON_GATEWAY_REQUIRE_ENGINE_LOCK). |
engine_lock_timeout_ms | 60000 | Engine lock wait budget (LEMON_GATEWAY_ENGINE_LOCK_TIMEOUT_MS). |
enable_webhook, require_engine_lock and engine_lock_timeout_ms are validated as boolean, boolean and positive integer respectively.
A2A peers
[gateway.a2a] configures the A2A v1.0 peer transport; enable it with gateway.enable_a2a = true. See the A2A peers guide for setup. The listener binds loopback 127.0.0.1:9901 by default; remote binds require token secret names (validated at load).
| Key | Default | Meaning |
|---|---|---|
host, port | 127.0.0.1, 9901 | Listener bind (LEMON_A2A_HOST, LEMON_A2A_PORT). |
public_url | unset | Advertised URL (LEMON_A2A_PUBLIC_URL). |
name, description | Lemon, a short description | Agent card identity. |
agent_id | default | Lemon agent profile that answers peers. |
skills | ["coordination", "coding"] | Advertised skills. |
reply_timeout_ms | 300000 | Positive integer (LEMON_A2A_REPLY_TIMEOUT_MS). |
rate_limit_per_minute | 60 | Positive integer (LEMON_A2A_RATE_LIMIT_PER_MINUTE). |
max_context_turns | 100 | Positive integer (LEMON_A2A_MAX_CONTEXT_TURNS). |
default_allow_tools | read read_skill recall grep find ls webfetch websearch kanban peer | Tools inbound peer runs may use unless a peer overrides it. |
Each [gateway.a2a.peers.<name>] sets url, token_secret (or separate inbound_token_secret / outbound_token_secret), agent_id, capabilities, timeout_ms and allow_tools (empty means default_allow_tools). Tokens are secret names, never literal values.
Gateway Projects and Bindings
When LemonGateway handles a Telegram message, it can optionally map that chat (or topic/thread) to a named project. A project is a working directory root plus optional agent and queue defaults.
Why it matters:
- The native executor runs with
cwdset to the project root (so file edits/commands happen in the right repo). - The gateway loads per-project config from
<project_root>/.lemon/config.toml(which can override agent profiles, models, tool policy, etc. compared to your global~/.lemon/config.toml). - If a chat has no bound project, runs use
gateway.default_cwdwhen configured, otherwise the assistant home (~/.lemon/agent/workspace, or$LEMON_AGENT_DIR/workspace). Channel runs never default to your$HOME.
Projects
Define projects under [gateway.projects.<project_id>]:
[gateway.projects.myrepo]
root = "/path/to/myrepo"Bindings
Bindings connect an incoming chat scope to a project, agent profile, and queue defaults:
[[gateway.bindings]]
transport = "telegram"
chat_id = 123456789
# Optional: bind this chat to a project (must match the `[gateway.projects.<id>]` key)
project = "myrepo"
# Optional: choose which agent profile to use (defaults to "default")
agent_id = "default"
queue_mode = "steer"Notes:
- If you omit
project, LemonGateway will run withcwdset togateway.default_cwdwhen configured, otherwise the assistant home (~/.lemon/agent/workspace). - You can also bind at the topic/thread level by setting
topic_idin the binding (takes precedence over the chat-level binding when a matching topic exists). topic_idcorresponds to Telegram'smessage_thread_id(only present in forum topics).- LemonGateway loads
gateway.*config on startup; after changinggateway.projectsorgateway.bindings, restart the gateway process.
Optional fallback cwd:
[gateway]
default_cwd = "~/"Tip:
- In Telegram, you can set or inspect the current chat/topic working directory at runtime with
/cwd [project_id|path|clear]. /new <project_id|path>still works, and setting/cwdmakes future/newsessions in that chat/topic use the same directory./newconfirmation replies include model, provider, cwd, and session context details.- If you pass a path, Lemon will register it as a project named after the last path segment (e.g.
~/dev/lemon=> projectlemon).
XMTP (Base App / Wallet Chats)
Lemon can run as an XMTP bot through the lemon_channels XMTP adapter.
1. Install XMTP bridge dependencies
./bin/lemon-xmtp-bootstrapThis installs bridge dependencies in apps/lemon_channels/priv/node_modules, next to xmtp_bridge.mjs, where Node resolves its imports.
2. Configure gateway XMTP
[gateway]
enable_xmtp = true
[gateway.xmtp]
env = "production" # production | dev | local
wallet_address = "${XMTP_WALLET_ADDRESS}"
wallet_key_secret = "XMTP_WALLET_KEY" # secret ref; env fallback works if XMTP_WALLET_KEY is set
db_path = "~/.lemon/xmtp-db"
poll_interval_ms = 1500
connect_timeout_ms = 15000
require_live = true # production default: do not allow mock fallback
mock_mode = false # set true only for local bridge testing
# Optional:
# api_url = "https://api.xmtp.network"
# inbox_id = "..."
# sdk_module = "@xmtp/node-sdk"Notes:
- When
enable_xmtp = true, Lemon auto-registers and starts the XMTP channel adapter. require_live = truekeeps health/readiness red unless the bridge is truly live (not mock mode).wallet_key_secretis the canonical credential field. It can point to a Lemon secret name or to an env var name when using secret resolution with env fallback.- Non-text XMTP messages currently receive a text-only fallback response.
Voice
Voice transport is configured under [gateway.voice].
[gateway.voice]
enabled = true
websocket_port = 4047
public_url = "https://example.com"
twilio_account_sid_secret = "twilio_account_sid"
twilio_auth_token_secret = "twilio_auth_token"
twilio_phone_number = "+1234567890"
deepgram_api_key_secret = "deepgram_api_key"
elevenlabs_api_key_secret = "elevenlabs_api_key"
elevenlabs_voice_id = "21m00Tcm4TlvDq8ikWAM"
elevenlabs_output_format = "ulaw_8000"
llm_model = "gpt-4o-mini"
system_prompt = "You are a helpful phone assistant."
max_call_duration_seconds = 600
silence_timeout_ms = 5000Canonical voice settings are loaded from gateway.voice. Legacy :lemon_gateway app env fallbacks remain only as temporary compatibility shims and should not be used for new setup.
Channel Voice Replies
Voice replies are opt-in separately for each channel, under [gateway.<channel>.voice_replies]; they are unrelated to the phone-call transport at [gateway.voice].
[gateway.telegram.voice_replies]
enabled = true
provider = "openai"
model = "gpt-4o-mini-tts"
voice = "alloy"
base_url = "https://api.openai.com/v1"
api_key_secret = "openai_api_key"
# api_key = "..." # optional direct credential; prefer encrypted secretsThe same table can be configured for other channel IDs. enabled defaults to false; provider, model, endpoint and credentials are independent of chat-model settings. Only replies to inbound voice messages are eligible. The text answer is always delivered as the transcript, including when speech synthesis fails. Speech synthesis is asynchronous and starts only after all final transcript chunks are confirmed delivered. If transcript delivery fails, audio is withheld. Provider responses are limited to 20 MiB; oversized audio leaves text-only delivery. Credentials may be supplied with api_key or an encrypted-store reference api_key_secret; secret-resolution failures must not fall back to a different key.
price_per_million_characters optionally sets the configured speech model's USD price per million input characters (a non-negative number). Set it only from your provider's applicable model tariff; there is no built-in assumed price. Update it when changing models, and leave it absent for models billed only by tokens or audio duration. When speech returns no reported cost (as OpenAI's binary speech response does), Lemon computes input_characters * price_per_million_characters / 1_000_000. An explicitly reported provider cost takes precedence, including zero. The canonical usage ledger retains input_characters (Elixir String.length/1, Unicode graphemes) alongside token counts, without retaining speech text. With no applicable price, it retains the character count and records cost_usd: nil, price_known: false; it never treats missing pricing as free speech or invents token usage.
Telegram Voice Transcription
If enabled, Telegram voice notes are transcribed and the transcript is routed as a normal text message.
[gateway.telegram]
voice_transcription = true
voice_transcription_provider = "openai_transcribe"
voice_transcription_model = "gpt-4o-mini-transcribe" # optional
voice_max_bytes = 10485760 # optional (default: 10MB)
# Optional OpenAI-compatible endpoint override. Credentials default to [providers.openai].
voice_transcription_base_url = "https://api.openai.com/v1"Use openai_transcribe with provider credentials for real speech-to-text. local_transcript has no local STT implementation: it returns :no_local_stt, shows the existing voice-transcription failure message, and never submits a run. It does not generate preview text or transcribe audio.
mix lemon.doctor skips the check when transcription is disabled and warns when credentials are missing or local_transcript is selected.
Telegram File Transfer
Enable /file put and /file get (and optional auto-save for plain document uploads).
[gateway.telegram.files]
enabled = true
auto_put = true
auto_put_mode = "upload" # "upload" | "prompt"
auto_send_generated_files = true # optional: send generated files automatically after a run
auto_send_generated_max_files = 3 # optional: max generated files auto-sent per run (default: 3)
uploads_dir = "incoming"
media_group_debounce_ms = 1000 # optional (default: 1000ms)
# Optional safety rails
allowed_user_ids = [123456789] # if empty, group uploads require admin
deny_globs = [".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"]
max_upload_bytes = 20971520 # optional (default: 20MB)
max_download_bytes = 52428800 # optional (default: 50MB)
outbound_send_delay_ms = 1000 # optional: delay between auto-sent files/batches to reduce 429sCommands:
/file put [--force] <path>: upload a Telegram document into the active working root./file get <path>: fetch a file (or zip a directory) from the active working root back into Telegram.
If no project is bound for the chat, the active root falls back to gateway.default_cwd (or the assistant home, ~/.lemon/agent/workspace).
When auto_send_generated_files = true, Lemon tracks generated files requested by browser/media tools and sends up to auto_send_generated_max_files files back to Telegram automatically at completion (using the same max_download_bytes limit as /file get). auto_send_generated_images remains accepted as a backward-compatible alias. SVG outputs are uploaded as Telegram documents instead of photos because Telegram rejects SVG photo processing.
Discord File Transfer
Enable Discord file transfer and optional generated-file auto-send.
[gateway.discord.files]
enabled = true
auto_put = true
auto_send_generated_files = true # optional: send generated files automatically after a run
auto_send_generated_max_files = 3 # optional: max generated files auto-sent per run (default: 3)
# Optional safety rails
max_upload_bytes = 26214400 # optional (default: 25MB)
max_download_bytes = 26214400 # optional (default: 25MB)When auto_send_generated_files = true, Lemon tracks generated files requested by browser/media tools and sends up to auto_send_generated_max_files files back to Discord automatically at completion. Generated files must fit within max_download_bytes; explicit file-send requests are still delivered through the normal attachment path. auto_send_generated_images remains accepted as a backward-compatible alias.
Discord Trigger Mode and Message Content Intent
Discord defaults to mention-gated routing in group channels and public threads. Use /trigger all to opt a channel or thread into free-response routing for unmentioned messages, and /trigger mentions to restore the default behavior.
[gateway.discord]
message_content_intent_enabled = true # diagnostics declaration only
default_account_id = "default" # optional account for ./bin/lemon send account-scoped lookups
default_channel_id = "1475727416549969980" # optional default for ./bin/lemon send --to discord
default_thread_id = "1475727416549969991" # optional threadmessage_content_intent_enabled does not change Discord application settings. It lets channel_diagnostics.json record that the operator has enabled the privileged Message Content Intent in the Discord Developer Portal. Free-response Discord support requires that portal setting plus a passing live external-sender proof; mention-triggered Discord prompts do not depend on this declaration.
Telegram Context Compaction
When a Telegram run approaches the model context limit, Lemon can proactively mark the session for compaction so the next user message is automatically rewritten with a compact transcript and sent as a fresh session.
[gateway.telegram.compaction]
enabled = true
context_window_tokens = 400000 # optional override; if unset Lemon infers from the model and native executor
reserve_tokens = 16384 # optional safety margin before limit
trigger_ratio = 0.9 # optional; 0.9 means trigger at 90% of context windowTelegram Pairing and Access
The Telegram adapter fails closed. It accepts updates only from chats listed in gateway.telegram.allowed_chat_ids. With an empty or missing allowlist the bot is unpaired: it drops every message and callback, logs a warning, and replies at most once per chat every 10 minutes with "This bot is not paired. Run lemon gateway setup telegram." lemon doctor reports an enabled, unpaired Telegram adapter as a failure (channels.telegram.allowlist).
/whoamiis answered in any chat, paired or not, with the chat id and your user id. Use it to find the id of a group you want to add toallowed_chat_ids.lemon gateway setup telegrampairs interactively: it asks you to send/startto the bot, waits up to--pair-timeoutseconds (default 120), and writes the chat id. For scripts:lemon gateway setup telegram --non-interactive --token <token> --chat-id <id>(--chat-idis repeatable).allow_all_chats = trueexplicitly accepts every chat when no allowlist is set. Anyone who finds the bot can then run the agent; doctor reports it as a warning.
In group chats (group, supergroup, channel peers) the router requires an approval for every bash, write, edit, patch, and hashline_edit call, because any member can prompt the agent.
Telegram and Discord DM pairing codes
Set unauthorized_dm_behavior = "pair" in [gateway.telegram] or [gateway.discord], then restart Lemon. An unknown human DM receives an eight-character code and asks the owner to run:
lemon pairing list
lemon pairing approve telegram <CODE>
# Or: lemon pairing approve discord <CODE>
lemon pairing deny telegram <CODE>
lemon pairing revoke telegram <CHAT_ID>Approval and revoke take effect on the running gateway immediately; no restart is needed, including after a supervised adapter restart. The allowlist change is also written to the user config for later boots. A configured but empty Discord allowed_channel_ids = [] denies every channel (an absent key leaves channels unrestricted), so revoking the last channel stays closed across restarts.
The default, "ignore", preserves existing behavior: Telegram's empty-allowlist setup hint remains rate-limited, non-allowlisted chats are dropped, and Discord retains its configured guild/channel restrictions. In "pair" mode, Discord DMs require an explicit channel allowlist entry even if the allowlist was absent. Groups, guild channels, bots, callbacks and slash-command interactions do not mint codes. Telegram /whoami continues to answer without authorization.
Codes expire in one hour, are single-use and platform-bound, and only their SHA-256 digests are stored. Each platform admits at most three pending requests. Repeated DMs reuse the pending request and receive at most one code reply every ten minutes. After a runtime restart a pending code is still approvable, but cannot be displayed again because plaintext is never persisted; wait for expiry if lost. list shows platform, masked chat ID, display name and age, never codes. Names are untrusted: confirm the sender out of band before approving.
Only the local operator CLI approves; the runtime list/consume method requires operator pairing scope and a direct loopback connection. Chats cannot approve themselves. Approval consumes the code and appends the chat ID to the user config's Telegram allowed_chat_ids or Discord allowed_channel_ids through the same TOML patcher as gateway setup. It does not loosen guild restrictions for guild traffic; an explicitly allowed DM can operate independently of guild membership. Project config overrides still take precedence. Restart Lemon after approval: the config watcher reloads canonical config but existing adapters retain their startup authorization state. No automatic confirmation is sent. If config writing fails after consumption, fix the config and ask for a fresh request.
Trigger Mode (Mentions-Only)
Telegram group chats (like Discord) default to mentions-only: Lemon runs only on @botname, a reply to the bot, or a slash command. Direct messages always run.
/trigger: show current trigger mode./trigger mentions: only run on@botname, reply-to-bot, or slash commands (default)./trigger all: run on all messages in this chat or topic (group admins only)./trigger clear: clear a topic override (forum topics only)./cwd [project_id|path|clear]: show, set, or clear the chat/topic working directory override used by future/newsessions.
Forum topic management:
/topic <name>: create a new topic in the current Telegram forum supergroup.
Learning Reminders (skills.json)
Learning reminder settings use the existing skills configuration, notconfig.toml: global ~/.lemon/agent/skills.json (or LEMON_AGENT_DIR/skills.json) and project <cwd>/.lemon/skills.json. Project keys override global keys.
{
"learning_nudge": {
"enabled": true,
"memory_turn_interval": 10,
"skill_tool_threshold": 15
}
}| Key | Default | Meaning |
|---|---|---|
enabled | true | Enable main-session in-band reminders; false skips reminders and forgets that session's counters on its next turn. |
memory_turn_interval | 10 | User turns without remember before suggesting a save; repeats at this interval if no save is made. |
skill_tool_threshold | 15 | Tool dispatches in the previous completed run, without skill_manage, before a one-turn skill creation/patch suggestion. |
background_review.enabled | false | Replace in-band reminders with isolated reviews of completed eligible private main-session runs. |
background_review.model | parent model | Optional Lemon model specification (for example openai:gpt-4o-mini); uses configured provider credentials. |
background_review.min_interval_ms | 300000 | Minimum time between review submissions per live session (maximum 86400000); reviews never overlap. |
background_review.max_digest_bytes | 4096 | Maximum redacted untrusted digest bytes (clamped to 256–16384 to retain the trust envelope); individual summaries use memory ingest's 2000-byte truncation budget plus its truncation marker. |
background_review.max_tool_turns | 3 | Maximum assistant tool-use turns (maximum 10). |
background_review.timeout_ms | 60000 | Review execution timeout (maximum 300000). |
Numeric settings must be positive integers; invalid values use their defaults. Configuration is read at the next user turn. With background review disabled, the fixed reminder text is bounded volatile context with no extra model calls, memory reads, automatic saves or transcript scans. Enabling background review costs one additional model run per accepted trigger, using the parent model unless background_review.model selects a cheaper configured model. Reviews use only private remember and draft-create/non-pinned-patch skill_manage, never shared grants or channel delivery. Subagents, cron/sub-sessions, review sessions and internal followups are excluded. Limits/counters disappear on session exit or restart. See learning reminders.