All configuration lives in a single TOML file. Default location: ~/.denkeeper/denkeeper.toml.

Top-level keys

KeyTypeDefaultDescription
data_dirstring"~/.denkeeper"Base directory all default paths derive from. Overridden by DENKEEPER_DATA_DIR
max_toolsint50Combined ceiling on tools + plugins

[telegram]

KeyTypeDefaultDescription
tokenstringrequiredBot token from @BotFather
allowed_usersint[]requiredTelegram user IDs allowed to interact

[discord]

KeyTypeDefaultDescription
tokenstringrequiredDiscord bot token
allowed_usersstring[]requiredDiscord user snowflake IDs

[llm]

KeyTypeDefaultDescription
default_providerstring"openrouter"Name of the provider instance to use by default (must match a configured instance name)
default_modelstring"anthropic/claude-sonnet-4-20250514"Model identifier (format depends on provider)
cost_limit_softfloat0Soft cost limit per session in USD (warns but continues)
cost_limit_hardfloat1.0Hard cost limit per session in USD (stops generation)
stream_idle_timeout_secsint120Seconds a streaming LLM response may sit idle before the request is aborted

[[llm.providers]]

Named provider instances. Multiple entries of the same type are allowed, enabling e.g. OpenAI + a local LM Studio endpoint simultaneously. Each instance is addressable by its unique name.

KeyTypeDescription
namestringUnique instance name (used in default_provider and per-agent llm_provider)
typestringProvider type: "anthropic", "openai", "openrouter", or "ollama"
api_keystringAPI key (required for all types except ollama)
base_urlstringAPI endpoint override (useful for Azure, vLLM, LM Studio, or a gateway in front of OpenRouter)
organizationstringOpenAI organization ID (openai type only)
cost_limit_softfloatSoft per-session cost limit in USD for this provider instance (warns but continues)
cost_limit_hardfloatHard per-session cost limit in USD for this provider instance (stops generation)
default_rate_per_1k_tokensfloatFallback per-1k-token rate used when a model has no pricing entry
model_pricestablePer-model pricing overrides, keyed by model ID: input, output, cached_input (all USD per million tokens) — see below
[[llm.providers]]
name = "openai"
type = "openai"
api_key = "sk-..."

[[llm.providers]]
name = "lmstudio"
type = "openai"
base_url = "http://localhost:1234/v1"
api_key = "lm-studio"

[[llm.providers]]
name = "anthropic"
type = "anthropic"
api_key = "sk-ant-..."
cost_limit_hard = 5.0

[llm.providers.model_prices."claude-opus-4-1"]
input = 15.0
output = 75.0
cached_input = 1.5

Legacy single-slot syntax ([llm.openai], [llm.anthropic], etc.) is still supported and auto-converted at startup. The two styles can coexist; an explicit [[llm.providers]] entry with the same name takes precedence.

[llm.openrouter] (legacy)

KeyTypeDefaultDescription
api_keystringrequiredOpenRouter API key
provider_order[]stringnoneExplicit preference list of upstream provider slugs (e.g. "moonshotai") that overrides sticky routing when set — usually unnecessary
provider_allow_fallbacksboolunset (OpenRouter default: allowed)Whether OpenRouter may fall back to providers outside provider_order
provider_ignore[]stringnoneUpstream provider slugs (e.g. "inceptron") OpenRouter must never route to. Sent on every request, alongside provider_order and sticky routing. TOML only; the Providers page doesn’t edit it yet
provider_stickybooltruePrefer the last-served upstream provider for provider_sticky_ttl after a successful response, so upstream prompt caching keeps hitting. Reset by upstream errors (429/5xx/network), not by client cancellation or 4xx
provider_sticky_ttlduration string"1h"How long to keep the sticky provider preference

[llm.openrouter.reasoning]

Controls the reasoning parameter sent to OpenRouter.

KeyTypeDefaultDescription
enabledboolinferred true if effort or max_tokens is setActivate reasoning with model defaults
effortstringnone"xhigh", "high", "medium", "low", "minimal", or "none". Mutually exclusive with max_tokens
max_tokensint0Reasoning token budget. Mutually exclusive with effort
excludeboolfalseOmit reasoning from the response (tokens are still billed)

[llm.anthropic] (legacy)

KeyTypeDefaultDescription
api_keystringrequiredAnthropic API key (sk-ant-...)
base_urlstring"https://api.anthropic.com"API endpoint override

[llm.ollama] (legacy)

KeyTypeDefaultDescription
base_urlstring"http://localhost:11434"Ollama server URL

[llm.openai] (legacy)

KeyTypeDefaultDescription
api_keystringrequiredOpenAI API key
base_urlstring"https://api.openai.com/v1"API endpoint override (for Azure OpenAI, vLLM, LiteLLM, etc.)
organizationstring—OpenAI organization ID (optional)

Compatible with any endpoint that speaks the OpenAI Chat Completions API format.

[[llm.fallback]]

KeyTypeDescription
triggerstring"cost_limit", "rate_limit", or "error"
actionstring"switch_provider", "switch_model", or "wait_and_retry"
providerstringTarget provider (for switch_provider)
modelstringTarget model (for switch_model)
scopestring"soft" or "hard" (for cost_limit) — which agent cost limit triggers the swap
max_retriesintMax retry count (for wait_and_retry)
backoffstring"exponential" (default) or "constant"

cost_limit rules consume the agent’s cost_limit_soft / cost_limit_hard (resolved via [[agents]] overrides or the global [llm] defaults). Legacy low_funds rules with a threshold field auto-migrate to cost_limit + scope = "soft" on load.

[[llm.deciders]]

Named decision models: non-generative classifiers such as typesafe/jev-1.13 that answer typed questions with probabilities instead of text. A decider cannot chat, so it is never selectable as an agent model.

KeyTypeDefaultDescription
namestringrequiredUnique decider name (lowercase alphanumeric with hyphens)
providerstringrequired[[llm.providers]] instance serving the model. Must be of type openrouter
modelstringrequiredDecision model ID
timeoutduration"5s"Per-call timeout
max_input_tokensint30000Calls whose estimated input exceeds this are refused, never truncated

Spend is taken from the provider-reported cost and counts against the calling session’s cost limits. Everything sent to a decider (tool arguments, conversation excerpts) goes to that provider’s upstream, an additional data processor.

[session]

KeyTypeDefaultDescription
tierstring"supervised"Default permission tier: "autonomous", "supervised", "restricted"
approval_timeoutstring"5m"How long to wait for operator approval before timing out. Go duration string
approval_retriesint0Times to re-submit a timed-out approval before reporting failure to the LLM

Note that approval_timeout is the engine’s wait, distinct from the 24-hour TTL after which an unresolved approval request is expired and cleaned up.

[agent]

Defaults for agents that do not set their own directories.

KeyTypeDefaultDescription
persona_dirstring<data_dir>/agents/<name>Base persona directory
skills_dirstring<data_dir>/skillsGlobal skills directory

[[agents]]

KeyTypeDefaultDescription
namestringrequiredUnique agent name (one must be "default")
descriptionstring—Agent description
persona_dirstring—Path to persona files
skills_dirstring—Override the global skills directory. Agent-specific skills in <persona_dir>/skills/ are always loaded regardless, and override global skills by name
adaptersstring[]—Adapter bindings (e.g., ["telegram"], ["telegram:12345"])
llm_providerstring—Override default provider (must match a configured provider instance name)
llm_modelstring—Override default model
session_tierstring—Override default permission tier
timezonestring—IANA name, e.g. "Australia/Sydney". Precedence: agent > api.timezone > UTC. Does not affect cron evaluation, which stays on api.timezone and is restart-only
cost_limit_softfloat—Per-agent soft cost limit in USD (overrides global)
cost_limit_hardfloat—Per-agent hard cost limit in USD (overrides global)
max_context_messagesint50Most recent messages sent to the LLM. Older history is dropped from the request, not deleted
max_tool_roundsint50Tool-call rounds per turn, not calls — one round may fan out to many parallel calls
browser_url_allowliststring[]—Overrides the global browser allowlist for this agent. Supports *.example.com
auto_approve_toolsstring[]—Tool names auto-approved for this agent without human sign-off (config scope). Declared in TOML only — cannot be created or removed at runtime; re-applied wholesale on config reload. Names not matching an advertised tool are warned about and kept
[[agents.fallback]]table array—Per-agent fallback rules. When non-empty these replace the global [[llm.fallback]] rules rather than merging with them

Supervisor

KeyTypeDefaultDescription
supervisorstring—Name of another agent that auto-reviews tool calls before they reach you (supervised tier only; supervisor must be autonomous or restricted, not itself supervised). An empty session_tier counts as [session] tier, which defaults to supervised, so give the supervisor an explicit tier
supervisor_timeoutstring"30s"Max wait for the supervisor’s LLM review. Go duration format (30s, 1m, 90s). On timeout, falls through to human approval
supervisor_context_messagesint5Number of recent conversation messages passed to the supervisor as context
supervisor_body_excerpt_lenint500Max characters of skill body included in the review prompt
supervisor_tool_desc_lenint200Max characters of tool description included in the review prompt
supervisor_max_args_bytesint16384Max bytes of tool-call arguments sent for review. Larger calls skip the supervisor and go straight to human approval (audited with cause: "too_large"); arguments are never truncated. 0 = default
supervisor_deciderstring—[[llm.deciders]] entry that scores each tool call (aligned, safe arguments, scoped) before the supervisor. Supervised tier only; works with or without supervisor
supervisor_decider_modestring"shadow""shadow": the decider runs and writes a supervisor audit event (source = "decider:<name>") with the verdict it would have given, but never changes the outcome. "enforce": the verdict approves or denies the call, and an uncertain one goes on to the supervisor or a human
supervisor_decider_approve_atfloat0.95The decider approves when every answer is at or above this probability
supervisor_decider_deny_atfloat0.05The decider denies when any answer is at or below this probability. Must satisfy 0 < deny_at < approve_at < 1

Decider spend is billed to the reviewed agent, per conversation. A decider failure (timeout, cost limit, input too large) is audited and never approves anything. See Decision Models for how to move from shadow to enforce.

A config reload applies supervisor and the five supervisor_* knobs above to running agents: adding, changing, or removing a supervisor takes effect for the next tool call, and removing a knob restores its default. Renaming an agent through the API or dashboard also updates other agents’ supervisor and any [[channels]] agent that named it.

A config reload applies every decider change: adding, removing or switching supervisor_decider, changing a decider’s provider or model, and mode and thresholds. A review already in progress finishes with the old decider. PATCH /api/v1/agents/{name} and the dashboard apply at once, without a reload.

Post-turn reviewer

A headless per-agent engine that reviews after a turn and can append persona memory or report skill improvements. Distinct from the supervisor above — it reviews after the fact rather than gating tool calls, and is capability-reduced rather than approval-gated.

KeyTypeDefaultDescription
reviewer_modelstring—Model used for post-turn review. Empty disables post-turn review for this agent
reviewer_providerstring—Provider for the reviewer; inherits the agent’s llm_provider when empty
review_max_iterationsint6Tool-call rounds allowed to the reviewer
review_timeoutstring"2m"Max duration of a review pass
nudge_memory_intervalint0User turns between memory-review nudges. 0 = disabled
nudge_skill_intervalint0Tool-call rounds between skill-review nudges. 0 = disabled

[[channels]]

Named routing endpoints that decouple sessions from a 1:1 agent–adapter binding. A channel points at one agent and may bind several adapters, which lets one conversation be shared across them. When no [[channels]] are declared, Denkeeper synthesizes them from each agent’s adapters list.

KeyTypeDefaultDescription
namestringrequiredUnique channel name. Conversation ID is chan:{name}
agentstringrequiredAgent that handles messages on this channel
adaptersstring[]—Adapter bindings, same format as [[agents]] adapters. Empty means the channel is reachable only via /session or the API
deliverystring"single"How scheduled messages are delivered: "single" (first specific binding) or "broadcast" (all specific bindings)
session_modestring"persistent""persistent" keeps one conversation per channel; "ephemeral" starts a fresh one per interaction (conversation ID chan:{name}:{unix_nano}). Cross-adapter ephemeral channels are rejected at validation

Users switch channels at runtime with /session <name>; the selection is persisted. Resolution priority is: active /session override > specific binding > wildcard binding > legacy agent-adapter fallback.

[memory]

KeyTypeDefaultDescription
db_pathstring"<data_dir>/data/memory.db"SQLite database path
retention_daysint90How long conversations are kept. 0 = unlimited
max_conversationsint10000Cap on stored conversations. 0 = unlimited
cleanup_intervalstring"1h"How often retention is enforced
persona_memory_char_limitint2200Cap on MEMORY.md size in characters. 0 in TOML falls back to this default — there is currently no way to request an unlimited cap
persona_user_char_limitint1375Cap on USER.md size in characters. 0 in TOML falls back to this default — there is currently no way to request an unlimited cap

[log]

KeyTypeDefaultDescription
levelstring"info""debug", "info", "warn", "error"
formatstring"text""text" or "json"

[voice]

KeyTypeDefaultDescription
stt_providerstring—Speech-to-text provider ("openai")
tts_providerstring—Text-to-speech provider ("openai")
tts_voicestring"alloy"Voice name
auto_voice_replyboolfalseReply with voice when user sends voice

[voice.openai]

KeyTypeDefaultDescription
api_keystringrequiredOpenAI API key for STT/TTS

[web]

Built-in web_search and web_fetch tools. Restart-only — [web] settings are not hot-reloaded.

KeyTypeDefaultDescription
enabledbooltrueEnable web search/fetch tools for agents

[web.search]

KeyTypeDefaultDescription
providerstring"duckduckgo"Search backend: "duckduckgo" or "tavily"
api_keystring—Provider API key (required for Tavily)
max_resultsint5Number of search results to return

[web.fetch]

KeyTypeDefaultDescription
timeoutstring"30s"HTTP request timeout
max_size_bytesint5242880Raw response body size limit (5 MB)
max_response_charsint8000Characters of converted Markdown returned per web_fetch call (max 100000); longer pages paginate via start_index. Each pagination round re-reads the full conversation context, so 24000–32000 usually serves a whole article in one call. Also editable in the Server Config dashboard page
user_agentstring"Denkeeper/1.0 (+https://denkeeper.io)"HTTP User-Agent header
respect_robots_txtboolfalseCheck robots.txt before fetching
respect_agents_txtboolfalseCheck agents.txt before fetching

[web.fetch.jina]

KeyTypeDefaultDescription
enabledboolfalseEnable Jina Reader as a fallback fetcher for JS-heavy pages

[script]

Bounds for the in-process run_javascript tool, which runs short ES5.1 snippets against a JSON input in a fresh sandboxed VM per call. There is no network, no filesystem, and no require. Disabled entirely in the restricted tier.

KeyTypeDefaultDescription
enabledbooltrueEnable run_javascript
timeoutstring"2s"Per-call wall-clock limit
max_output_charsint16000Result length cap (truncates)
max_input_bytesint262144Accepted input payload cap, 256 KiB (rejects)
max_concurrentint4Simultaneous VM executions across all agents. Negative = unlimited
max_concurrent_per_agentint0Additional per-agent cap so one agent cannot monopolize the global pool. 0 = off

There is no per-VM heap cap. max_concurrent bounds the memory multiplier but is not a hard ceiling — lower it if you run on constrained hardware.

[decide]

Gives every agent an in-process decide tool that puts typed questions about a JSON state to a decision model and returns probabilities. Off until a decider is named.

KeyTypeDefaultDescription
deciderstring—[[llm.deciders]] entry the tool calls. Empty means no tool
enabledbooltrueSwitch the tool off without unnaming the decider. true without a decider is rejected

The tool is classified read-only, so it is available in the restricted tier and in dry runs, and identical calls within one turn are served from cache. Spend is billed to the calling agent under a per-day session key and counts against its cost limits. The state an agent passes goes to the decider’s provider. Changes to the named decider apply on reload; turning [decide] on or off, or naming another decider, needs a restart, like [script].

[skills]

KeyTypeDefaultDescription
max_bytesint1048576Cap on a single persisted skill file, frontmatter + body (1 MiB). Negative = unlimited

Skill content is written verbatim, so without a bound an authorized caller could exhaust disk. The cap is enforced on every write surface (REST, config MCP, external MCP).

[browser]

Containerized browser automation. Off by default.

KeyTypeDefaultDescription
enabledboolfalseEnable browser automation
imagestring"ghcr.io/temikus/denkeeper-browser:latest"Browser plugin container image
memory_limitstring"512m"Container memory limit
cpu_limitstring"1"Container CPU limit
profile_dirstring"data/browser-profiles"Per-agent profile directory, relative to data_dir
session_ttlstring"10m"Idle session close timeout
max_pagesint5Concurrent pages per agent

[browser.url_allowlist]

KeyTypeDefaultDescription
domainsstring[]—Domains the browser may navigate to. Empty = unrestricted. Supports *.example.com

Individual agents can narrow this further with browser_url_allowlist on [[agents]].

[costs]

KeyTypeDefaultDescription
default_rate_per_1k_tokensfloat0Fallback rate (USD per 1K tokens) when a model is in neither the bundled registry nor your overrides. 0 records $0.00 and logs a warning

Denkeeper ships a pricing registry covering roughly 70 models. Lookup priority is: provider-reported cost > registry exact match > registry longest-prefix match > default_rate_per_1k_tokens > $0 with a warning. The winning source is recorded as the pricing_source telemetry attribute, so you can tell a real price from a fallback.

[costs.model_prices.<model>]

Override or add pricing for a model. Rates are USD per million tokens.

KeyTypeDefaultDescription
inputfloat—Input token rate
outputfloat—Output token rate
cached_inputfloat0Cached-input rate. 0 means “same as input”
[costs.model_prices."my-org/custom-model"]
input = 3.0
output = 15.0
cached_input = 0.3

[audit]

KeyTypeDefaultDescription
enabledbooltrueEnable audit logging
retention_daysint30How long audit events are kept. 0 = unlimited
cleanup_intervalstring"1h"How often retention is enforced
buffer_sizeint1000Capacity of the in-memory event buffer. Emission never blocks an agent turn: events past a full buffer are dropped with a warning log

Events are queryable via GET /api/v1/audit and the dashboard’s Audit Log page.

[eval]

KeyTypeDefaultDescription
auditstring"full"How much of a dry-run or eval turn reaches the audit log. "full" records it like a live turn; "summary" keeps only lifecycle events and errors
max_concurrentint2Eval samples running at once, process-wide across all runs
max_cost_per_runfloat2.0Default USD ceiling for one eval run, overridable per run. There is no uncapped value
default_kint3Samples per (task, variant) pair, giving the objective metrics something to average over
completeness_floorfloat0.8Fraction of expected samples that must succeed before a run’s scorecard is called conclusive
win_thresholdfloat0.55Blinded-pair judge win rate a candidate must reach to be called an upgrade
gate_rejected_rate_ppfloat2.0Largest tolerated rise in the rejected tool-call rate, in percentage points
gate_rounds_pctfloat20Largest tolerated rise in mean tool-call rounds per task, in percent
gate_cost_pctfloat25Largest tolerated rise in mean cost per task, in percent
judge_modelstring—Model the internal judge grades blinded pairs with. Empty means there is no internal judge and the MCP path is the only one
judge_providerstring—[[llm.providers]] instance serving the judge. Empty uses the base agent’s own provider
judge_max_cost_per_runfloatmax_cost_per_runUSD ceiling for one judging pass across both judge backends, a separate budget from the run’s sample cap
judge_deciderstring—[[llm.deciders]] entry asked before judge_model. Empty means no decider stage
judge_decider_record_atfloat0.9Probability the decider’s winning option must reach for its verdict to be recorded. Must exceed 0.5
judge_decider_timeoutduration"60s"Per-item deadline for the judge decider, separate from the decider’s own timeout
captureboolfalseRecord live turns as turn traces. Off by default — a trace holds everything the model saw
max_trace_bytesint262144Per-trace payload cap (256 KiB). Over it, the oldest tool-call rounds are dropped first, then the oldest history messages
retention_daysint30How long captured traces are kept, matching the audit log

win_threshold and the three gate_* keys are the decision rule, and it is deliberately asymmetric: the three gates can declare a downgrade on their own (a failed gate needs no judge to reject a candidate) or report that nothing regressed, but calling a candidate an upgrade also requires the judge win-rate to reach win_threshold. A judge’s preference can never override an objective regression. GET /api/v1/eval/runs/{id}/summary returns the gate table with each value, delta, threshold and pass/fail alongside a one-line reason, plus a per-category breakdown so a candidate that wins on chat while regressing on tool-heavy tasks is visible rather than averaged away.

An eval run is bounded twice, by spend (max_cost_per_run) and by rate (max_concurrent); both are always in force. Writing 0 for any of these keys is indistinguishable from omitting it and yields the default — set a real value to change one. A run that hits its cost cap stops dispatching new samples, lets the in-flight ones finish, and keeps its partial results rather than discarding them.

Setting judge_model turns on the internal judge: POST /api/v1/eval/runs/{id}/judge works the same pending queue server-side, so a run can be judged unattended instead of only from Claude Code over MCP. It is capability-reduced by construction — one completion per item with no tool definitions in the request, reading only the same blinded payload eval_get_pair returns — so it can no more unblind its own queue than the MCP judge can. Its verdicts are ordinary verdicts under judge_ident judge_model, stamped with the rubric version, and they feed the same win rate. Judging spend is capped by judge_max_cost_per_run and recorded on the run as judge_cost, kept apart from the sample spend in cost_spent because the two are separate budgets. judge_model, judge_provider and judge_max_cost_per_run are re-read on config reload, so turning the judge on or moving its cap takes effect on the next pass; max_concurrent bounds judging process-wide and is fixed at start-up. Judging spend is attributed to the pseudo-agent {agent}#eval:judge, so it never lands in a real agent’s totals.

Setting judge_decider puts a decision model in front of the judge model. Each item is first put to the decider as five a/b/tie choice questions, the overall call plus one per rubric dimension, in one call. When the winning option’s probability reaches judge_decider_record_at the verdict is recorded under judge_ident judge_decider; otherwise, and on an oversized item, a timeout or an error, the item goes to judge_model when set, or stays pending for the MCP judge. judge_decider works alone too. judge_decider, judge_decider_record_at and judge_decider_timeout all apply on reload.

capture, max_trace_bytes and retention_days are the turn-trace knobs. A trace records what a turn actually saw: the system prompt as it was assembled after skill injection, the history window as it went on the wire, every tool call with its arguments and the result the model read, the final response, timings and usage. The dashboard’s Turn inspector renders them, which is what answers “why did it do that” — the audit log carries rounds and outcomes but never the payloads.

Live capture is off by default and should stay off unless you want that record: a trace is the most sensitive data Denkeeper stores. Eval samples are traced regardless of the switch, because the judge reads the trace and a verdict has to stay re-checkable; those turns never touch a live conversation. Traces get their own retention_days rather than riding on [memory]’s for the same reason.

Dry-run turns persist nothing — no messages, telemetry, or memory — and execute only idempotent tools; everything else returns a suppressed marker. "full" is the default because a preview that is audited like a live turn is easier to trust; the resulting noise is handled by marking rather than by recording less. Preview events are attributed to a pseudo-agent ({name}#dryrun / {name}#eval:{variant}) and carry source = dryrun/eval, so the Audit Log page’s “Previews” toggle can filter them out of both the event list and the statistics.

[api]

KeyTypeDefaultDescription
enabledbooltrueEnable the REST API server and web dashboard
listenstring":8080"Bind address
tlsboolfalseEnable HTTPS
cert_filestring—TLS certificate path
key_filestring—TLS private key path
cors_originsstring[]—Allowed CORS origins
rate_limitfloat0Max requests/sec per API key
websocket_enabledbooltrueEnable the WebSocket endpoint (GET /api/v1/ws)
websocket_max_connectionsint0Maximum concurrent WebSocket connections (0 = unlimited)
websocket_replay_buffer_ttlstring"5m"How long to buffer events for replay after a client disconnects
external_urlstring—Publicly-reachable base URL (used for OAuth callback URLs; defaults to http(s)://<listen>)
timezonestring"UTC"IANA timezone used for cron evaluation and as the fallback for agents without their own timezone. Cron evaluation is restart-only
login_rate_limitint5Failed password logins allowed per window per IP
login_rate_windowstring"15m"Window for login_rate_limit
onboarding_dismissedboolfalseSet by the dashboard when the onboarding checklist is dismissed
wizard_completedboolfalseSet by the dashboard when the setup wizard finishes or is left for later
wizard_skippedboolfalseSet by the dashboard when the setup wizard is left with “Set up later”, so the Overview keeps offering to resume it. Cleared when the wizard finishes

[[api.keys]]

Static API keys, in addition to any created at runtime via the Keys CLI, the keys REST endpoints, or first-run setup. All keys — static and runtime — are stored and checked the same way.

KeyTypeDefaultDescription
namestringrequiredUnique, human-readable key name
keystringrequiredThe token clients send as Authorization: Bearer <key>
scopesstring[]requiredPermissions this key grants; ["admin"] grants everything
[[api.keys]]
name = "ci"
key = "dk_..."
scopes = ["chat", "sessions:read"]

Valid scopes: admin, chat, health, sessions:read, sessions:write, costs:read, agents:read, agents:write, skills:read, skills:write, schedules:read, schedules:write, approvals:read, approvals:write, tools:read, tools:write, browser:read, browser:write, kv:read, kv:write, audit:read, channels:read, channels:write, eval:read, eval:write. admin implies every other scope.

[api.mcp_server]

Exposes this Denkeeper instance as an MCP server, so an external MCP client (another agent, an IDE) can drive its agents, skills, schedules, and audit log. Opt-in.

KeyTypeDefaultDescription
enabledboolfalseEnable the MCP server endpoint
transportstring"streamable""streamable" or "sse" (legacy)
session_timeoutstring"30m"Idle session cleanup duration
chat_timeoutstring"2m"Maximum time for a single chat tool call
statelessboolfalseDisable session tracking

Note the direction: [tools.*] is Denkeeper connecting outward to MCP servers; this section is Denkeeper being one.

[[schedules]]

KeyTypeDefaultDescription
namestringrequiredUnique schedule name
typestringrequired"system" or "agent"
schedulestringrequiredCron expression, interval, or named schedule
skillstring—Skill to invoke
agentstring"default"Target agent
session_tierstring"supervised"Permission tier for this schedule
channelstring—Delivery channel (e.g., "telegram:12345")
tagsstring[]—Freeform labels
enabledbooltrueEnable/disable without removing
session_modestring"shared""shared" reuses the target channel’s existing conversation history; "isolated" starts a fresh conversation with no prior context for each run. Note: schedules created via POST /schedules default to "isolated" instead — the default differs by creation path

[plugins.*]

KeyTypeDefaultDescription
typestringrequired"subprocess" or "docker"
commandstringrequiredPlugin binary path (subprocess) or Docker image (docker)
argsstring[]—Command-line arguments
envmap—Environment variable overrides
capabilitiesstring[]required["tools"]
memory_limitstring—Docker container memory limit (e.g., "256m")
cpu_limitstring—Docker container CPU limit (e.g., "0.5")
networkstring"none"Docker network mode ("none", "bridge", etc.)
volumesstring[]—Docker bind mounts

Subprocess plugins run as child processes with direct MCP stdio. Docker plugins run in hardened containers with --cap-drop ALL, --read-only, --security-opt no-new-privileges, and --network none by default.

[security]

KeyTypeDefaultDescription
trusted_keysstring[]—Paths to PEM-encoded Ed25519 public key files
allow_unsignedbooltrueAllow unsigned subprocess plugin binaries

When allow_unsigned = false, all subprocess plugin binaries must have a valid Ed25519 signature from one of the trusted keys.

[safety.reply_guard]

Runtime guardrail that holds back an obviously broken final reply on schedule-driven turns — a live user reacts to a bad reply, a schedule fires unattended. Distinct from [security], which covers plugin signing. The turn is still stored in full and an audit event lands under category safety, action reply_guard; what changes is the delivered text, replaced by a one-line notice. Dry runs and evals evaluate the guard and report the verdict on the transcript, but never substitute the text.

Each signal takes "withhold", "warn" (audit but deliver anyway), or "off".

KeyTypeDefaultDescription
enabledbooltrueMaster switch
on_role_markupstring"withhold"Reply carries role/tool-call scaffolding as plain text (<rs_tool_calls>, <|im_start|>, "\n\nHuman:", …) instead of calling a tool
on_oversizedstring"withhold"Reply exceeds max_reply_bytes or max_completion_tokens
on_no_tool_callsstring"warn"A schedule named a skill and the turn made no tool calls at all; only flags, since some skills legitimately finish without tools
on_leaked_tool_callstring"withhold"The final reply carries a tool call rendered as plain text (functions.kv_get:0{...}) — an upstream that failed to parse the model’s native call format. The router retries such a response once before the guard sees it
max_reply_bytesint16000Caps the final reply in bytes (~4 Telegram chunks, under half that adapter’s own render limit). Negative disables
max_completion_tokensint0Caps provider-reported completion tokens. 0 disables — it measures the same thing as max_reply_bytes
excerpt_bytesint200How much of the held reply reaches the audit detail. Negative disables

[kv]

KeyTypeDefaultDescription
max_keys_per_agentint1000Maximum keys per agent
max_value_bytesint65536Maximum value size in bytes (64 KB)
list_max_bytesint16384Total value bytes a single kv_list response may carry
list_value_head_bytesint1024Per-value cap inside a kv_list response
cleanup_intervalstring"1h"Background cleanup interval for expired keys
default_ttltablenoneExpiry applied by kv_set when the call passes no ttl, keyed by namespace prefix (must end in :). Longest matching prefix wins; an explicit ttl always wins. Example: default_ttl = { "log:" = "720h", "cache:" = "168h" }

Per-agent key-value storage with optional TTL. Exposed as Config MCP tools (kv_get, kv_set, kv_delete, kv_list, kv_set_nx). Useful for locks, counters, caches, and cross-session coordination.

list_max_bytes and list_value_head_bytes are sized for model context, not disk: a kv_list over a busy namespace can otherwise return tens of thousands of tokens in one tool result. Keys are always returned in full; values are what gets dropped once the budget is spent. Both must be positive, and list_value_head_bytes must not exceed list_max_bytes.

[sandbox]

KeyTypeDefaultDescription
runtimestring"docker"Sandbox backend: "docker" or "kubernetes"

Selects the runtime backend for sandboxed (Docker-type) plugins.

[sandbox.kubernetes]

KeyTypeDefaultDescription
namespacestring"denkeeper-sandboxes"Kubernetes namespace for sandbox Pods
kubeconfigstring—Path to kubeconfig file (empty uses in-cluster config)
runtime_classstring—RuntimeClassName for gVisor or Kata Containers

The Kubernetes backend creates ephemeral Pods with init-container network isolation, dropped capabilities, read-only root filesystem, and Pod Security Admission labels. Supports both in-cluster (ServiceAccount) and out-of-cluster (kubeconfig) authentication.

[mcp]

Global settings that apply to all MCP tool servers.

KeyTypeDefaultDescription
request_timeout_secsint30Per-request timeout for MCP calls (0 = no timeout)
auto_restartbooltrueAutomatically restart crashed stdio servers
max_restart_attemptsint3Consecutive failures before disabling a server
restart_cooldownstring"5m"Duration a server must stay connected to reset the failure counter
drain_timeoutstring"35s"How long teardown waits for in-flight tool calls before forcing the transport closed
url_allowliststring[]—Allowed hostnames/wildcards for SSE tool server URLs (empty = all non-blocked hosts)
health_fail_thresholdint3Consecutive health-probe failures before a health_fail audit event fires for remote (sse/http) servers. Stdio servers always emit on the first failure
init_retry_attemptsint5Retries for a server’s initial connection attempt
init_retry_backoffstring"2s"Base backoff duration between initial-connection retries
sse_keep_alive_secsint15TCP keepalive interval for SSE connections (overridable per server)
env_passthroughstring[]—Extra parent-process environment variable names forwarded to stdio server subprocesses, on top of the built-in non-secret allowlist. DENKEEPER_* and other denylisted names are always blocked, even here

Removing, disabling, restarting or reconfiguring a tool server tears it down in two phases. The server stops being offered immediately — its tools leave the advertised set and new calls are refused — and only then does Denkeeper wait for the calls already running to finish, up to drain_timeout, before closing the transport. The server reports status draining while it waits. A window that expires forces the close and records a forced_close event in the audit log with the number of calls that were still running. The default sits just above the 30s per-tool-call timeout, so a call that was going to succeed gets to.

[tools.*]

KeyTypeDefaultDescription
transportstring"stdio"Transport type: "stdio" (subprocess) or "sse" (remote HTTP/SSE)
commandstringrequired for stdioMCP server command (stdio only)
argsstring[]—Command arguments (stdio only)
envmap—Environment variables; supports ${NAME} placeholder expansion (stdio only)
urlstringrequired for sseRemote server URL (SSE only, must be http/https)
headersmap—HTTP headers sent with SSE requests (SSE only)
request_timeout_secsint0Per-server timeout override (0 = use global [mcp] value)
sse_keep_alive_secsint0Per-server keep-alive override (0 = use global [mcp] value, SSE only)
authstring""Authentication method: "" (none) or "oauth" (OAuth 2.1, SSE only)
client_idstring—OAuth2 client ID (optional; some servers use dynamic registration)
client_secretstring—OAuth2 client secret (optional; must be set together with client_id)
scopesstring[]—OAuth2 scopes to request (optional)
disabled_toolsstring[]—Tool names on this server to exclude from the LLM’s tool payload
idempotentboolfalseMemoize identical calls to this server’s tools within one message turn (identical name+args returns the cached result instead of re-executing). Only set on servers whose tools are all read-only — a cached write is a silently dropped side effect
idempotent_toolsstring[]—Per-tool memoization opt-in for servers that mix read and write tools; union with idempotent
trust_annotationsboolfalseAlso memoize tools this server marks read-only via the MCP readOnlyHint annotation. Annotations are self-declared by the server — enable only for servers you trust to describe their tools honestly
env_passthroughstring[]—Additional parent-process env vars to forward into this stdio server’s subprocess, on top of the built-in allowlist and the global [mcp] env_passthrough. DENKEEPER_* and other secret-matching names are always filtered out
allow_loopbackboolfalseBypass SSRF protection against localhost/127.x/::1 for this server’s url (SSE only) — unsafe, only for a server you control
guidancestring""Operator-authored argument rules for this server, injected into the system prompt of every agent that can call its tools, next to the server’s tool list, and into the supervisor’s review prompt when a call to one of its tools is reviewed. Argument conventions only (ID provenance, forbidden filter grammar, fields the API accepts but that must be omitted) — workflow belongs in skills. Max 2000 bytes: rejected over the cap via REST/UI, truncated with a warning at load. A server with no callable tools (disabled, failed, every tool in disabled_tools) contributes nothing. Restart-only when hand-edited in TOML; applied immediately when written through REST/UI

SSE security: SSRF protection blocks localhost, link-local (169.254.x.x), and cloud metadata endpoints. ${NAME} placeholders in url and headers are resolved from environment but secrets matching DENKEEPER_*_SECRET, DENKEEPER_*_PASSWORD*, and related patterns are denied. URL and header values are redacted in API responses.

Tools can also be added and removed at runtime via the REST API (tools:write scope) or the Config MCP server (tool_add/tool_remove). Runtime changes are persisted to the TOML config file.

[otel]

KeyTypeDefaultDescription
enabledboolfalseEnable OpenTelemetry instrumentation
traces_endpointstring—OTLP HTTP endpoint for trace export (e.g. "http://localhost:4318")
service_namestring"denkeeper"Service name for the OTel resource

Env overrides: DENKEEPER_OTEL_ENABLED sets enabled, DENKEEPER_OTEL_TRACES_ENDPOINT sets traces_endpoint.

When enabled, Prometheus metrics are exposed at GET /metrics (no auth required). Traces are only exported when traces_endpoint is set.

[api.auth]

KeyTypeDefaultDescription
password_hashstring—bcrypt hash from denkeeper passwd CLI
session_secretstring—Hex-encoded AES-256 key (64 hex chars). Generate with openssl rand -hex 32
session_max_agestring"24h"Session cookie lifetime
preferred_login_methodstring"auto"Which login method the login page shows first: "auto", "password", or "apikey"
session_record_retentionstring"720h"How long to keep session records after they expire

Env override: DENKEEPER_API_AUTH_SESSION_SECRET sets session_secret.

[api.auth.oidc]

KeyTypeDefaultDescription
enabledboolfalseEnable OIDC SSO
issuerstring—OIDC provider issuer URL (e.g. "https://accounts.google.com")
client_idstring—OAuth2 client ID
client_secretstring—OAuth2 client secret
redirect_urlstring—Callback URL (e.g. "https://denkeeper.example.com/auth/callback")
scopesstring[]["openid","email","profile"]OAuth2 scopes
allowed_emailsstring[]—Email allowlist. Required non-empty when enabled. Case-insensitive.

Env overrides: DENKEEPER_OIDC_CLIENT_ID sets client_id, DENKEEPER_OIDC_CLIENT_SECRET sets client_secret.

Requires email_verified: true claim from the OIDC provider. Uses Authorization Code flow with PKCE (S256).