Configuration
Codumentor is configured through YAML, typically named codumentor.yaml. That file is one layer of configuration. It is the instance only when you pass --config or the process uses workspace home (the usual enterprise-edition server). Community / user-home runs keep data under $CODUMENTOR_HOME even when a project YAML is discovered as an overlay.
This file controls repositories to analyze, file patterns, model settings, and tool behavior.
Which configuration guide is this? This is the complete operator reference — every option, including authentication, the API server, logging, and config inheritance. If you just want a single-user instance running quickly, start with the shorter Setup → Configuration. Community "install once, run anywhere" is in Community. To stand up a team/production instance end-to-end, follow the Self-Hosting Walkthrough.
Tip — edit config from a chat. Instead of hand-editing this YAML, admins can use the Configuration Assistant: an admin-only chat (Admin page → Configuration Assistant) that changes the running instance's config in plain language. It grounds itself in this reference and the live schema, validates the change, shows you the exact YAML diff and whether it applies live / needs a restart, and only writes after you approve — with a timestamped backup and one-step rollback. Enable it with the
configuration_assistantplugin. Passtarget=userfor models and keys,target=projectfor repos and project plugins, or a loaded file's path (anextends:base, for instance) to edit that file.
Two axes: config layers vs instance home
These are independent. Mixing them is allowed; naming them is the product.
Config layers (what the process believes), later wins:
bundled flavor default → user overlay → project overlay → --config
Instance home (where bytes live): workspace (next to the YAML) · user ($CODUMENTOR_HOME) · ephemeral (tempdir).
Community without --config uses user home. A discovered codumentor.yaml is then a project overlay (repos / project plugins only; it cannot redirect model URLs or auth). Enterprise edition with a project YAML uses workspace home: that YAML is the instance. --config is always trusted.
See How a process finds its configuration below for origins and the untrusted-overlay strip.
Environment Variable Substitution
All string values in the configuration file support environment variable substitution using ${VAR_NAME} syntax. This allows you to keep sensitive values (like API keys) or environment-specific paths out of your configuration files.
Example:
models:
agent: "${AGENT_MODEL}"
agent_base_url: "${BASE_URL}/v1"
embedding_api_key: "${GOOGLE_API_KEY}"
vector_db:
path: "${DATA_DIR}/vector_db"
repos_dir: "${REPOS_DIR}"
Environment variables are substituted when the configuration is loaded. If a variable is not set, the original ${VAR_NAME} string is kept as-is.
Note: Environment variable substitution works for all string values throughout the configuration, including nested dictionaries and list items.
Config inheritance with extends
You can let one config build on another using the extends key. The value can
be a relative path (resolved from the file that declares it) or an absolute
path. Extends are applied depth-first: parents load first, then the child
overrides them.
# child.yaml
extends: base.yaml # or a list: [base.yaml, extras/feature.yaml]
models:
agent: "gemini-2.0-pro" # overrides the parent value
vector_db:
path: "./data/vector_db_variant" # override while inheriting other settings
Cycles are detected and raise an error. Extends works together with the CLI
-c/--config merging: each file is resolved with its own extends chain, then
the resulting configs are merged in the order you pass on the CLI.
How a process finds its configuration
An effective config is always a merge, later wins:
bundled flavor default → user overlay → project YAML / --config
- Bundled — packaged
default_<flavor>.yaml. This is the product floor: omitted keys inherit the flavor file.--flavor minimal -c overlay.yamlstarts the minimal product without the overlay repeating the plugin list. - User overlay —
$CODUMENTOR_HOME/config.yaml(default~/.codumentor/config.yaml). If the file does not exist, the layer is skipped. Relocate the directory withCODUMENTOR_HOME. - Project /
--config— discoveredcodumentor.yaml(or the flavor filename) and any files passed with-c. Later files win.
codumentor status and GET /ui/config report which layers contributed.
A discovered project YAML (found in CWD or via the git-root search, not
passed as --config) is an overlay when the instance home is user
(community / cd into a clone). That overlay cannot override inference
redirects: models.* base URL and API key fields, and the entire auth:
section. A cloned repo can still name repos and the agent model; it cannot
point the process at an attacker's proxy. When the YAML is the instance
(workspace home, enterprise edition), it is trusted. Explicit --config is always
trusted.
| Origin | When |
|---|---|
| bundled | The packaged flavor default. Always the first layer on a loader-produced config. |
| user | $CODUMENTOR_HOME/config.yaml contributed (skipped if the file is missing). |
| explicit | You passed -c / --config PATH. |
| discovered | The flavor's default filename (codumentor.yaml, or codumentor-<flavor>.yaml) was found in CWD, at the git root, or in the git root's parent. |
| ephemeral | Retained for reload of processes that recorded this origin before the floor flip. New no-YAML loads record the bundled file as bundled; instance home kind=ephemeral is the persistence axis. |
Inspect a running (or about-to-run) process:
codumentor status # banner: instance home, workspace, config, flavor
codumentor status --json
Under the workspace line, both the banner and status list each repository's
resolved on-disk root — the answer to "is this process pointed at the
checkout I meant". It is worth reading, because a relative repos_dir (and a
relative path: in repos:) is anchored to the directory holding the config
file, never to the directory you launched from: launch inside a worktree
whose config says repos_dir: ".." and the roots are the parent's checkouts.
The startup banner stops after five roots and says how many it withheld;
codumentor status prints them all, and status --json carries them as
workspace.repos.
GET /ui/config includes the same facts as instance_home and config_layers.
Migration: omitted keys now inherit the flavor file
Before this floor flip, a workspace YAML was composed with Pydantic defaults
for any key it omitted. Config.include_patterns defaults to [] ("ingest
by known extension"); default_enterprise.yaml ships a language whitelist
instead. A YAML that never set include_patterns used to mean ingest-by-extension
and now inherits the flavor whitelist.
**If you omitted include_patterns in order to ingest every known
extension, set it explicitly:**
include_patterns: []
The same rule applies to every other key the flavor file sets
(session_report, logging.level, exclude_patterns, plugins, …).
Keys the flavor file also omits still use Pydantic defaults. Complete
files such as codumentor-selfdev.yaml are unchanged: they already set
the keys they care about.
Config() constructed in tests (not via load_config) still uses
Pydantic defaults only.
flavor:
flavor: enterprise # or personal | marketing | minimal | root | investigate
The build-time edition (what the binary contains) is a different axis;
see Editions. The old config key edition: is aliased onto
flavor: with a startup warning — update it when convenient, but a file that
still says edition: keeps booting on the flavor it names.
--flavor / CODUMENTOR_FLAVOR / invoking codumentor-<flavor> select the
bundled default_<flavor>.yaml floor (and the first-run scaffold when no
file exists). An existing file that sets flavor: still wins. An explicit
--flavor that the merge result contradicts is a hard error.
Configuration File Structure
The configuration file uses the following structure:
repos:
- "https://github.com/user/repo1.git"
- "path/to/local/repo2"
repos_dir: "repos"
# include_patterns: [] # empty = ingest every known extension; omit = flavor whitelist
# exclude_patterns use sensible defaults (see below)
ingestion:
max_file_size: 1048576 # 1 MB (default) — skip files larger than this
chunking:
size: 1000
overlap: 100
models:
embedding: "text-embedding-004"
embedding_base_url: "https://generativelanguage.googleapis.com/v1beta/openai/"
agent: "gemini-2.0-flash"
agent_base_url: "https://generativelanguage.googleapis.com/v1beta/openai/"
vector_db:
backend: "chroma"
path: "./data/vector_db"
cache:
embedding_enabled: true
embedding_db_path: null
embedding_max_age_days: 365
agent:
max_iterations: 10
storage_dir: "./data/agent_storage"
tools:
tool_result_max_content_length: 30000 # Max chars before tool output is chunked
shell_output_max_size: 10240
file_listing_max_size: 10240
shell_timeout: 900
search_concurrency: 5
permissions:
allow: [] # operator allow rules (a floor; read-only over the API)
deny: [] # operator deny rules — highest priority anywhere
default_mode: acceptEdits # default | acceptEdits | auto | plan | bypassPermissions
allow_bypass: true # false: users cannot choose "Always allow"
allow_auto: true # false: users cannot choose Auto
reason_argument: required # required | optional | off
api:
host: "127.0.0.1"
port: 2638
reload: false
enable_web_ui: true # Serve the protocol-native /ui/* API + SPA
enable_openai_api: false # Legacy OpenAI-compatible /v1/* surface (opt-in)
stream_flush_ms: 250 # Coalesce streamed deltas into one UI event per beat (0 = per-chunk)
stream_flush_bytes: 4096 # Flush the delta buffer early once it reaches this size
ui:
welcome_title: "Welcome to Codumentor"
welcome_description:
en: "Your own Intelligent Work Environment (IWE). As the orchestrator, set the direction and coordinate the work of the AI agents across your entire software ecosystem — from interconnected services and legacy systems to undocumented codebases. Decide what happens, approve important operations, and keep the process in your hands throughout."
hu: "A Codumentor a saját Intelligens Munkakörnyezeted (IWE). Orchestrátorként meghatározod az irányt, és összehangolod az AI-ügynökök munkáját a teljes szoftveres ökoszisztémádban — az összekapcsolt szolgáltatásoktól és legacy rendszerektől a dokumentálatlan kódbázisokig. A fontos műveletek jóváhagyása nálad marad, a folyamatot pedig végig kézben tartod."
disabled_extensions: []
auth:
provider: "none" # Options: "none", "gitea"
gitea:
base_url: "https://gitea.example.com"
token_cache_ttl_minutes: 30 # Token cache TTL in minutes
prompt:
template_dir: "prompts"
system_prompt_template: "system_prompt.jinja2"
max_iterations_notification_template: "max_iterations_notification.jinja2"
custom_instructions: []
debug_mode: false
show_tool_calls: true
logging:
level: "INFO"
file: "./logs/codumentor.log"
logger_levels: {}
plugins:
- module: "codumentor.plugins.filesync"
class: "FileSyncPlugin"
Configuration Options
repos
List of repositories to analyze. Entries can be either simple strings (Git URLs or local paths) or structured objects that describe alternative SCM providers:
repos:
# Git support (string form)
- https://github.com/example/project.git
# Explicit configuration for alternate SCMs provided by plugins
- scm: svn
url: https://svn.example.com/repos/legacy
name: legacy_svn
options:
branch: trunk
# Pin to a branch/commit
- url: https://github.com/example/project.git
branch: release # or commit: <sha> / pin: <tag>
# Inline ref pin using URL suffix (common git syntax)
- https://github.com/example/project.git@release
- https://github.com/example/project.git#v1.2.3
# Use an existing local checkout
- path: /opt/shared/repo
branch: feature/search # optional: will checkout this ref before ingestion
# Pre-synced directories (requires filesync plugin)
- scm: filesync
name: shared_docs
path: "\\\\fileserver\\engineering\\docs"
# Ingest only part of a repository
- name: knowledge-repo
path: /srv/kb
ingest_paths: knowledge # or a list: [knowledge, docs]
scm(optional) selects the repository provider. Defaults to"git".urlis used for network-backed repositories (Git, SVN, etc.).pathcan point to an existing local directory. Pair with thefilesyncplugin if the directory should be synchronized from a remote share before ingestion.nameoverrides the derived repository name used inside Codumentor.optionsis a free-form dictionary passed to custom SCM plugins.branch/commit/pinlet you lock a repository to a specific ref (checked out after clone/update).- Inline pin format is supported on git URLs:
https://...repo.git@branchor...git#tag. ingest_paths(optional) lists the repo-relative directories ingestion may walk — a single string or a list. Omitted (the default) means the whole repository, so existing configurations are unaffected.description(optional) is a one-line summary of what the repository holds (e.g."Billing backend, C# / .NET 8"). The agent sees it next to the repository's name, type and checked-out branch in its repository list, which helps it pick the right repository among many. Cut at 160 characters.
##### Managing repositories from the UI
Administrators can add, edit and remove repositories from **Admin →
Repositories** (/admin/repos) instead of editing this file by hand. There is no
separate repository store: this YAML file remains authoritative. The page
edits the repos: list in the leaf config file (the last entry of
CODUMENTOR_CONFIG_FILES) and then drives the same hot-reload the
/admin/config button does, so a hand-edit on the host and a UI edit can never
disagree. Requires the admin:config permission.
What it writes, for an entry added through the form:
repos:
# your existing entries and their comments are left untouched
- name: service # the identity: the clone folder, the file-key prefix
# and the @-mention token
url: https://gitea.example.com/team/service.git
branch: develop # omitted entirely when you keep "Remote default"
options:
platform: gitea # pre-filled from the host when recognizable
An scm: svn entry, and an scm: filesync one:
repos:
- name: legacy-erp
scm: svn
url: https://svn.example.com/erp/trunk
options:
username: builduser
password: ${SVN_PASSWORD} # a reference — never the password itself
revision: "48213" # omit to follow the latest
- name: shared-docs
scm: filesync
path: "\\\\fileserver\\projects\\docs" # the source; copied, never modified
options:
target_dir: /srv/codumentor/repos/shared-docs
clear_target: true # the target is emptied before every copy
Notes on behaviour worth knowing before you rely on it:
setup_scriptanddev_serverare part of the form. They were not before, and because an edit writes the complete desired state of the fields the form owns, a save from a form that had never been shown a repository'sdev_server:block deleted it. They are now rendered under Advanced, and the nestedoptions:/dev_server:blocks are merged key-by-key rather than replaced — so comments inside them, and keys the form does not render, survive an edit.- Comments survive. Entries are edited by index through a round-trip YAML parser, so comments, quoting and key order on every other entry are preserved byte-for-byte. A timestamped
codumentor.yaml.bak.<UTC>still precedes every write. - Nothing is written unless it validates. The candidate file goes through the real loader pipeline first; a failure is reported and the file on disk is untouched.
- Adding is checked first. Before anything is written, the server probes the source:
git ls-remotefor a URL (bounded to 10s, with credential prompts disabled), or existence/readability for a path. A name collision, an occupied clone directory, or an unreachable host blocks the add and says why. Authentication failure is the one blockable-but-overridable case — you may add the repository anyway if the host's credentials will be provisioned before the clone runs. - Concurrent edits are detected. Each read hands out a digest of the file's bytes and each write must echo it back, so a second administrator's change (or a hand-edit on the host) is refused with a conflict rather than silently overwritten.
- Editing is partitioned by cost, and the form says which.
optionsanddev_serverapply immediately;branchandingest_pathstrigger a re-index (the button reads Save and re-index);name,url,pathandscmchange the repository's identity and sit in a separate collapsed section that requires typing the repository's name to confirm —scmbelongs there because it decides which tool owns the working copy, and a directory holding a git clone is not something the Subversion provider can update or a sync can copy into. An identity change is a remove-and-add in storage terms: a new clone folder, a full re-index, and the existing chunks orphaned under the old name until a full ingestion with cleanup runs. Fields the form does not show (commit,pin,setup_env_pass, anything you added by hand) are never touched by an edit. - Removal is three separate choices. The config entry always goes; its indexed chunks and, for a folder Codumentor itself cloned under
repos_dir, the on-disk copy are separate checkboxes, ticked by default (an operator who wants to keep the chunks or the clone unticks them before confirming), shown with the chunk count and the folder size so the choice is informed. Apath:entry — a directory this instance was pointed at rather than one it created — is never offered for deletion. The config change is written first, so a refused or failed write never destroys data; an artifact deletion that then fails (an ingestion holding the lock, for instance) is reported on the response rather than silently swallowed. - Inherited repositories work, with one asymmetry. When the leaf
extends:a base that declaresrepos:, adds and edits are written asrepos_overrides:entries — the base file is not modified, and the drawer says so before you type, because the resulting YAML will not look like the form. Removal has no override form: it requires materializing the merged list into this file, which permanently detaches it from the base (repositories added to the base later stop appearing). That needs an explicit confirmation in the dialog, and the materialized list is written with a comment saying what happened and when. Placeholders (${VAR},${secret:…}) are carried across unresolved. - Not editable everywhere. Running without a config file (
is_ephemeral), withCODUMENTOR_CONFIG_FILESunset, or with the config mounted read-only, the page renders read-only and names the reason — the repository list is still readable, and each row offers its YAML to copy. - Subversion and folder/share entries are creatable too. The drawer's Type selector picks the probe as well as the field set, because an SVN
https://URL and a git one are indistinguishable — so the tool is chosen, never guessed. Anscm: svnentry takes a username, a password reference and an optional revision; anscm: filesyncentry takes a source folder or UNC share plus the target it is copied into. Those credential fields sit above the check button, because for those two the credentials are an input to the probe rather than a detail to fill in afterwards. Any otherscm(one a plugin provides) stays listed, editable and removable, but is not offered as a choice — its field set belongs to that plugin. - A credential is written as a reference, never as a literal.
options.passwordmust be a single${VAR}reference; a typed password is refused by both the form and the API. This file is backed up on every write and readable by anything that can read the configuration, so it is not a secret store. Set the environment variable on the server and reference it here. The probe resolves the reference for its own call, so you learn whether it works before the entry is written, and the resolved value is scrubbed out of everything reported back. - When a remote refuses access, the page says what the host has. Whether an SSH agent is reachable and how many keys it holds, whether
~/.ssh/confignames that host, whichcredential.helpergit is configured with, and — for each${…}in the URL — whether it resolves in the server's own environment. Note that a${secret:…}reference cannot serve a clone: it is read from one user's own secrets inside a conversation, and a clone or a scheduled re-index runs in the background with no user attached. The panel says that rather than reporting it as unset. - Order is editable, and only where it is this file's to set. Per-row arrows rewrite the
repos:list; each entry's comment block moves with it, because the reorder moves whole line blocks rather than permuting parsed nodes (which is precisely what would leave every explanation attached to the wrong entry). An inherited list is refused with the reason: its order comes from the base file plus whateverrepos_overrides:appends. - Index details are opt-in. Show index details adds a second line per row: chunk and file counts, the newest indexed file's date, the revision the working copy is checked out at, and the size on disk. It is a separate request because each of those costs a metadata-table read, a directory walk or a subprocess — none of which belongs on a page load or a 2.5-second poll. The revision is deliberately not labelled "the revision that was indexed": nothing records that, so the honest statement is what the working copy holds now.
- Bulk re-index is one request. Tick rows and re-index the selection, or re-index everything. The task engine coalesces and serializes, so queueing eight repositories starts one walk and lines the rest up; a name that is no longer configured is reported rather than silently dropped.
- Changing
repos_diris offered, and is the one destructive-adjacent operation. It isREBUILD: every repository configured by URL is cloned again at the new location and fully re-indexed, and entries with their ownpath:are unaffected. The existing working copies are not moved or deleted — the dialog names the directory left behind, with its size, because nothing else in the product will mention it again. The probe refuses a relative path, the current value, a directory that overlaps the current one, and one that contains the vector database or this config file (ingestion would then index the search index or the configuration's own secrets). A non-empty target is a warning you can accept. Both a confirmation in the dialog and an explicitconfirmon the wire are required, so the consequence is not reachable by a client that skipped the form. A single-repository workspace is refused outright: thererepos_diris the project, so re-pointing it would not move a workspace. - Not supported yet: adding a second repository to a single-repo workspace where
repos_diris the repository (it would be cloned inside the first's working tree). That is refused with an explanation rather than half-applied.
##### ingest_paths: ingesting part of a repository
include_patterns / exclude_patterns are global; ingest_paths is per-repo,
and it narrows the walk rather than filtering its result, so excluded files
are never opened, hashed, or embedded. Use it when a repository's contents are
not homogeneous:
- An agentic-memory KB repo. Memory writes notes under
kb_base_dir(defaultknowledge/) and recall only ever returns notes from there — but everything else in that repo (goal working files, thedeprecated/archive, scratch) still got embedded and still competed for vector-search result slots.ingest_paths: knowledgekeeps it out of the index entirely. This is not derived from the plugin's own config: a plugin arg says where notes are written, not that the repo holds nothing else — auto-narrowing would silently stop indexing the codebase for anyone who keeps notes beside their code. - A large repo where only
docs/is worth answering questions from.
Notes:
- A path that names no directory on disk is ignored with a warning; if that leaves the repo with no valid scope, the whole repo is ingested (over- ingesting is recoverable, and a scope that matched nothing would be indistinguishable from "every file in this repo was deleted").
- Adding
ingest_pathsto an already-ingested repo is enough to clean it up: the next incremental ingestion reports the now-excluded files as deleted and removes their chunks. No--recreateneeded. @-mention file completion is deliberately unaffected — it indexes the repo independently, so files outside the ingestion scope stay mentionable.
Plugins can register additional SCM providers (e.g., "svn", "filesystem") to handle these entries during ingestion.
Generic *_overrides
Any top-level key can have a companion <key>_overrides entry. After configs
are merged (and env vars substituted), overrides are applied automatically:
- Dictionaries: deep-merged into the base dictionary
- Lists of plugin specs (
plugins_overrides): matched bymodule+class(or bynamewhen present) and deep-merged; unmatched entries are appended. The module path is normalized socodumentor.plugins.X,codumentor.src.codumentor.plugins.X, andcodumentor.plugins.X.pluginare the same plugin. Matching bynamealone is not how plugins work — plugin entries usually have noname. - Lists of other named items (dicts with a
namekey): matched by name and deep-merged; unmatched entries are appended - Other lists: appended to the base list
- Other types: replace the base value
repos_overrides entries can be plain URL strings (same formats accepted by
repos) — they are normalized to dicts before merging.
Examples:
# base.yaml
logging:
level: INFO
logger_levels:
codumentor.tools: WARNING
repos:
- name: kb
url: https://example.com/kb.git
branch: main
# variant.yaml
logging_overrides:
json_logging: true
logger_levels:
codumentor.tools: ERROR
codumentor.agent: DEBUG
include_patterns_overrides:
- "**/*.md"
repos_overrides:
# Match existing repo by name — only override the branch
- name: kb
branch: feature-branch
# Add a new repo using a plain URL string
- ssh://git@gitea.example.com:2244/org/sandbox.git
# Add a new repo with full dict syntax
- scm: svn
url: "https://svn.example.com/trunk"
name: legacy-svn
logging keeps level: INFO, enables JSON logging, and merges logger levels.
include_patterns gains the additional patterns while preserving the base ones.
repos gets the kb repo's branch updated to feature-branch, plus two new
repos appended.
##### Nested *_overrides
*_overrides also works deeper than the top level, using the same merge
rules. This is handy for surgically overriding one item in a nested list of
named items — e.g. a single LDAP directory under auth.ldap_directories —
without restating the whole list (a plain dict merge would replace it).
A nested <key>_overrides entry merges into its sibling <key> when present;
when <key> is absent (e.g. a parent config dropped it), the override applies
to an empty base, so list entries are added and dict values are set directly.
Because any key ending in _overrides is treated as a merge directive at
every level, config field names must never end in _overrides.
# base.yaml
auth:
provider: ldap
ldap_directories:
- name: corp
server_url: ldaps://corp.example.com
bind_dn: cn=svc,dc=corp
bind_password: pw
base_dn: dc=corp
security_mode: ldaps
- name: partner
server_url: ldaps://partner.example.com
bind_dn: cn=svc,dc=partner
bind_password: pw
base_dn: dc=partner
# variant.yaml — flip only the corp directory to STARTTLS
auth:
ldap_directories_overrides:
- name: corp
security_mode: starttls
corp keeps every other field and switches to starttls; partner is
untouched. An override entry whose name is not present is appended as a new
directory.
plugins
Codumentor supports plugins to extend functionality. Plugins are configured as a list of plugin specifications:
plugins:
- module: "codumentor.plugins.filesync"
class: "FileSyncPlugin"
- module: "codumentor.plugins.mcp"
class: "MCPPlugin"
args:
executable: "python"
args: ["-c", "from mssql_mcp_server import main; main()"]
module: Python module path to import the plugin class fromclass: Class name to instantiateargs: Optional dictionary of arguments to pass to the plugin constructorpriority: Optional priority for hook ordering (default: 100)
To change one plugin without restating the whole list, use plugins_overrides
on an extends: child. Entries match by module+class (see
Generic *_overrides above), not by name.
Workspace Isolation Plugin
The workspace isolation plugin sandboxes each conversation session using Linux bubblewrap with overlay filesystems. It requires bubblewrap with overlay support (apt install bubblewrap on current distros), an overlayfs-capable kernel, and — on Ubuntu 24.04+ — userns allowed for the bwrap binary under AppArmor; see Plugins in Production → Workspace isolation prerequisites for the install steps.
plugins:
- module: codumentor.plugins.workspace_isolation
class: WorkspaceIsolationPlugin
priority: 5
args:
enabled: true
workspace_ttl: 86400 # 24 hours
persistent_home: true # Persistent, shared $HOME (default: true)
subagent_isolation: shared # "shared" (default) or "isolated"
sandbox:
unshare_net: true # Block networking inside sandbox (default)
Key options:
| Option | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable/disable workspace isolation |
workspace_ttl | int | 86400 | Workspace time-to-live in seconds |
persistent_home | bool | true | Bind-mount a persistent HOME (<workspace>/home/) instead of tmpfs. When true, $HOME survives across sandbox invocations and is shared among the main agent, subagents, and external agents. Set to false for legacy ephemeral HOME. |
subagent_isolation | str | "shared" | "shared": subagents share the main agent's sandbox (simple, recommended). "isolated": each subagent gets its own sandbox with VCS integration and merge conflict resolution. |
sandbox | dict | {} | Sandbox hardening (see plugin README) |
repos_dir
Base directory where repositories will be cloned and stored.
What the file tools may reach outside it. read_file and ripgrep accept
an absolute path outside repos_dir — reading a scratch file the agent just
produced with shell (objdump -d prog > /tmp/dis.txt) no longer needs it
moved into the workspace first. There is no setting for this; the answer is
read from boundaries that are already declared elsewhere, and reads outside the
workspace are refused whenever one of them is in force:
| In force | Reads outside repos_dir | Why |
|---|---|---|
workspace_isolation (overlay mode) | refused | the sandboxed shell cannot see the host either — the file tools must not become a wider door than the shell |
An access profile scoped to named repos (repos: [names] or repos: none), or one that withholds the shell tool (qa_readonly) | refused | the operator declared a boundary; for a principal without shell, "it could read it anyway" is false. Note chat_only does hold shell — what refuses its host reads is the repository scope beside it, and the sandbox it is required to run under |
| Neither (the default single-host install) | allowed, after an approval prompt | shell is unconfined, so refusing the same path to read_file stopped nothing — but shell asks before reading a host file, so the file tools ask through the same permission provider (a Read(<path>) rule, auto-approved wherever shell is). Reads inside the workspace prompt for nothing |
Writes are never relaxed. write_file, replace_in_file and the Office
save tools stay confined to the workspace in every configuration, so a run
cannot leave artifacts outside it. Relative paths (../..) are always resolved
inside their repository — only an absolute path can name a host location.
include_patterns and exclude_patterns
File patterns using gitignore-style glob syntax. These control which files are
considered for ingestion. Files must also pass binary detection and the
max_file_size limit (see ingestion below).
include_patterns (flavor default: language whitelist; Pydantic: [])
When empty, all files with known extensions are included automatically.
Codumentor recognizes ~180 file extensions covering source code, config
files, templates, markup, and documentation. Files with unknown extensions
are still included if they pass a binary-detection heuristic (first 8 KB
checked for null bytes and printable-byte ratio).
default_enterprise.yaml ships a language whitelist (*/.py,
*/.md, …) rather than []. Because that file is the merge floor,
a YAML that omits include_patterns inherits the whitelist. Set
include_patterns: [] explicitly if you want ingest-by-extension.
See Migration: omitted keys now inherit the flavor file.
When non-empty, only files matching at least one pattern are included (whitelist
mode).
exclude_patterns (broad defaults)
# Default exclude_patterns:
exclude_patterns:
- "**/.git/**"
- "**/.svn/**"
- "**/.hg/**"
- "**/node_modules/**"
- "**/vendor/**"
- "**/packages/**"
- "**/.nuget/**"
- "**/__pycache__/**"
- "**/venv/**"
- "**/.venv/**"
- "**/*.egg-info/**"
- "**/.tox/**"
- "**/.mypy_cache/**"
- "**/.pytest_cache/**"
- "**/bin/**"
- "**/obj/**"
- "**/dist/**"
- "**/build/**"
- "**/out/**"
- "**/target/**"
- "**/.idea/**"
- "**/.vscode/**"
- "**/.vs/**"
- "**/.env/**"
- "**/coverage/**"
- "**/htmlcov/**"
- "**/*.min.js"
- "**/*.min.css"
Pattern syntax:
*.py— matches Python files in the root directory*/.py— matches Python files recursivelysrc/— matches everything under thesrcdirectory
Important: Setting include_patterns or exclude_patterns in your config
replaces the defaults entirely. To append patterns without losing the
defaults, use the _overrides suffix instead:
# REPLACES defaults — you lose all built-in excludes:
exclude_patterns:
- "**/my_custom_dir/**"
# APPENDS to defaults — built-in excludes are preserved:
exclude_patterns_overrides:
- "**/my_custom_dir/**"
The same applies to include_patterns_overrides.
ingestion
Controls document ingestion behavior:
max_file_size: Maximum file size in bytes (default: 1048576 = 1 MB). Files larger than this are skipped during ingestion. Set to0to disable the limit. Useful for excluding generated bundles, minified files, and large logs.streaming_threshold: Number of chunks above which streaming mode is used for stored file info retrieval (default: 10000)file_processing_batch_size: Number of files to process per batch during change detection (default: 1000)
chunking
Controls how documents are split into chunks for vector storage:
size: Target characters per chunk (default: 1000). A 200-character budget is reserved for the metadata prefix added during embedding, so effective chunk size issize - 200.overlap: Number of overlapping characters between chunks (default: 100)
models
Model configuration for different AI services:
embedding: Model ID for embedding generationembedding_base_url: Base URL for embedding APIembedding_max_input_length: Max input characters for the embedding model (optional). Overrides the built-in lookup table when set. Use this for local/unknown models, e.g.1700for a 512-token model.agent: Model ID for the main agentagent_base_url: Base URL for agent APIjudge: Model ID for evaluation/judging (optional, falls back to agent settings)judge_base_url: Base URL for judge API (optional)
API keys can be specified in the configuration or through environment variables:
api_key: General API key (fallback for all models)agent_api_key: Agent-specific API keyembedding_api_key: Embedding-specific API keyjudge_api_key: Judge-specific API key
Note: API keys support environment variable substitution using ${VAR_NAME} syntax (e.g., embedding_api_key: "${GOOGLE_API_KEY}"). See Environment Variable Substitution above for details.
Thinking/Reasoning Mode:
agent_thinking_enabled: Enable model thinking/reasoning mode (truein the shippedcodumentor.yaml; the field's own default isfalse, so a config assembled without the bundled layer adds no reasoning field to any request). It is also what gives the Thinking Modes Deliberation control something to move: with itfalsethe agent model derivescontrol: none, the effort dial greys out, and Fast and Slow become the same request. Whentrue, sendsenable_thinking: trueto the model viachat_template_kwargsand filters<think>...</think>blocks from the response shown to users. Works with both vLLM (which separates thinking into areasoningfield via--reasoning-parser) and external providers where thinking appears inline in content. Recommended for Qwen3.5 and similar models that support thinking mode. Example:agent_thinking_enabled: trueagent_show_thinking: Show captured thinking content in the UI as a collapsible block above each assistant message (default:false). Requiresagent_thinking_enabled: true. When disabled (the default), thinking is silently discarded after filtering — enabling it lets users inspect the model's reasoning. Short blocks (≤400 characters) open expanded; longer ones start collapsed. The user's last open/close action is remembered for subsequent blocks within a session. Example:agent_show_thinking: trueagent_reasoning_passback: Echo capturedreasoning_contentback to the API on subsequent requests (default:false). DeepSeek v3.2+ (e.g.deepseek-v4-pro) requires this on assistant turns that includetool_calls— without it the API returns400 "The reasoning_content in the thinking mode must be passed back to the API."Safe to enable for any provider that supports thereasoning_contentfield on assistant messages: DeepSeek and Qwen via vLLM/SGLang accept it (vLLM/SGLang silently drop it on input). Leavefalsefor providers that don't recognise the field (most Gemini/OpenAI/Anthropic-compatible endpoints), since they may reject the request. Example:agent_reasoning_passback: trueagent_reasoning_field: Field name used when echoing reasoning back (default:"reasoning_content"). Override only if your provider uses a different name. Example:agent_reasoning_field: "thinking"agent_reasoning_inline: Fold captured reasoning into the assistant message's rendered content instead of a sibling field (default:false). This exists becauseagent_reasoning_passbackalone is a no-op on vLLM and SGLang — they acceptreasoning_contenton input and discard it before the chat template renders, so the model never sees what it already worked out and re-derives its plan on every call.contentis the one field every chat template renders, so the inline form is what actually reaches the model on those endpoints. The reasoning is prepended to the message's own content inside a[my earlier reasoning]…[end of my earlier reasoning]block (the message's own text is preserved after it; a tool-calls-only assistant message ends up with the folded reasoning as its whole content). Independent ofagent_reasoning_passback— the two target different providers, so set either, both, or neither; with both on, both happen (the sibling field for providers that need it, such as DeepSeek v3.2+, plus the inline copy for providers that drop it). Costs prompt tokens on every request, which is why it is off by default. Example:agent_reasoning_inline: true
Truncated-reply retry policy:
When a reply comes back with finish_reason: length and carries no content and no tool call, the whole reply budget was spent on something unusable (usually reasoning). The agent loop re-rolls that call, bounded at two retries per turn, and then surfaces an error naming agent_max_tokens.
agent_truncation_retry_mode: What the re-roll should change (default:"same")."same"— the historic behaviour: the identical request, plus a short nudge. A fair bet when the overflow was incidental."less_deliberation"— ask for a cheaper reply on the retry only. On OpenAI-compatible ingresses that meanschat_template_kwargs: {enable_thinking: false}merged into the retry's request body (the field vLLM's and SGLang's chat templates read; it is the same leveragent_thinking_enabledpulls in the other direction, and the per-call override wins over it). A provider that does not render that key answers200and ignores it, so the retry is the historic retry — the policy degrades tosamerather than to something untested. The next call after the retried one is unchanged: this never leaks past the one re-roll. Example:agent_truncation_retry_mode: less_deliberationagent_truncation_retry_extra_body: State the retry's request-body patch outright instead of taking the provider default (default: unset). Merged one level deep over the request's ownextra_body. Ignored when the mode issame. Example:agent_truncation_retry_extra_body: {reasoning_effort: low}
How to tell it took effect. A knob can be accepted with a 200 and discarded before the chat template renders — agent_reasoning_passback above is exactly that on vLLM/SGLang — so "we sent the field" is not evidence. Codumentor logs the retry and its outcome as a pair at WARNING on the codumentor.agent logger:
truncated reply: retrying with {'extra_body': {'chat_template_kwargs': {'enable_thinking': False}}} (mode=less_deliberation), not the identical request
truncation retry probe: mode=less_deliberation attempt=1 overrides={...} -> finish_reason=stop reasoning_chars=0 (attempt that truncated: finish_reason=length reasoning_chars=46012)
If the second line reports the same finish_reason=length and a reasoning size of the same order as the attempt that failed, the ingress took the field and ignored it: the policy is inert and agent_truncation_retry_extra_body is where to state the spelling your ingress does read.
HTTP Timeouts:
read_timeout: HTTP read timeout in seconds for streaming responses (default: 300). This is the maximum time allowed between chunks during streaming. Increase for slow models, models with long thinking phases, or high-latency endpoints (e.g., RunPod). Example:read_timeout: 600
Model Profiles (per-subagent model selection):
By default, all subagents use the same model as the main agent. Model profiles let you assign different models to different subagent types — for example, a powerful reasoning model for the main agent and a cheaper, faster model for subagents.
A profile is a named bundle of model configuration: model ID, endpoint, API key, and optional parameters. Profiles are defined under models.profiles and referenced by name from subagent_default (applies to all subagents) or subagent_profiles (per-tool assignments).
models:
agent: "claude-sonnet-4-20250514"
agent_base_url: "https://api.anthropic.com/v1/"
agent_api_key: "${ANTHROPIC_KEY}"
profiles:
local-fast:
model: "qwen3-8b"
base_url: "http://localhost:8000/v1/"
api_key: "none"
cloud-cheap:
model: "gpt-4o-mini"
base_url: "https://api.openai.com/v1/"
api_key: "${OPENAI_KEY}"
# Default profile for all subagents
subagent_default: "local-fast"
# Per-tool assignments (take precedence over subagent_default)
subagent_profiles:
explore: "local-fast"
subagent: "cloud-cheap"
web_retrieve:
model: "gpt-4o"
base_url: "https://api.openai.com/v1/"
api_key: "${OPENAI_KEY}"
Resolution order: subagent_profiles[tool_name] > subagent_default > global agent settings.
Override values can be a profile name (string) or an inline profile definition (dict). When a profile omits api_key, the global agent API key fallback chain is used.
Available profile fields:
model(required): Model ID stringbase_url(required): API endpoint URLprovider: Which backend the profile talks to —"openai-compatible"(default; an OpenAI-compatible Chat-Completions endpoint authenticated withapi_key) or"openai-subscription"(a ChatGPT/Codex subscription over the Responses API, authenticated with an OAuth token). See ChatGPT/Codex subscription profiles below.codex_credentials_source: Forprovider: "openai-subscription"only — which credential store to read:"oauth"(per-user subscriptions connected in Settings) or"import"(default; the single operator bundle fromcodumentor codex import). See ChatGPT/Codex subscription profiles below.codex_account_id: Withcodex_credentials_source: "oauth"only — which of the user's connected ChatGPT accounts to read. Omit it when each user connects one account; a user with several gets an error naming them. (The ChatGPT connection a user's sign-in implies sets it per account.)fallback_profile: Forprovider: "openai-subscription"profiles — name of another profile (of any provider, typically a local model that does not share the subscription's allowance) to rotate to while this one is capped or throttled beyond a short wait, or has no connected credential. See Fallback on rate-limit indoc/plugin-dev/catalog/openai_codex.md.api_key: API key (optional — falls back to global agent key)extra_body: Extra parameters merged into every request (e.g.,chat_template_kwargs)thinking_enabled: Enable thinking/reasoning mode (default:false)show_thinking: Show thinking content in the UI (default:false)reasoning_passback: Echoreasoning_contentback to the API (default:false). Required for DeepSeek v3.2+ thinking models when used with tools.reasoning_field: Field name for reasoning passback (default:"reasoning_content")reasoning_inline: Fold reasoning into the assistant message's rendered content (default: unset = inheritagent_reasoning_inline). Settruefor vLLM/SGLang-hosted profiles, where thereasoning_passbacksibling field is silently dropped.rdc_injection_method:"user_message"or"system_message"(default:"user_message")read_timeout: HTTP read timeout in seconds (default: 300)context_window: Total prompt+completion budget the endpoint serves this model with (default: unset). Read by thecontext_summarizationplugin, which compacts againstmin(its own max_context_tokens, this)— so assigning a smaller-window model to a role narrows the compaction ceiling with it instead of overflowing on the first long turn. Unset leaves the plugin's configured ceiling in place. The global agent model's twin isagent_context_window.reasoning: How this model is asked to reason — see Reasoning capability below (default: unset = derived fromthinking_enabled).
ChatGPT/Codex subscription profiles (provider: openai-subscription)
A profile may be backed by a ChatGPT/Codex subscription instead of an API key.
This takes two fields — the second one is easy to miss and its default is
usually not what you want:
models:
profiles:
codex:
model: gpt-5.5
base_url: https://chatgpt.com/backend-api/codex/responses
provider: openai-subscription
codex_credentials_source: oauth # NOT the default — see below
codex_credentials_source selects the credential store, and the two stores are
entirely separate:
| Value | Reads | Populated by |
|---|---|---|
oauth | the oauth plugin's per-user token store | each user connecting their own account in Settings → Extensions (requires the oauth and openai_codex plugins) |
import (default) | one encrypted operator row | the host operator running codex login then codumentor codex import, once |
Omitting the field leaves it at import. A profile in that state ignores every
subscription connected in Settings and fails each turn with *"No ChatGPT/Codex
subscription credential configured"* — while the Settings page still shows the
account as connected, since that page reflects the OAuth store the profile is
not reading. If you connected the account through Settings, set
codex_credentials_source: oauth.
The choice is static: neither source falls back to the other. See
plugin-dev/catalog/openai_codex.md.
Reasoning capability (reasoning)
Each model exposes reasoning differently (Qwen hybrids a chat-template switch, OpenAI/OpenRouter an effort enum, gpt-oss a system-prompt string). The record declares that once, per profile. agent_reasoning is the same record for the global agent model.
levels is the ladder this model offers — the rungs the picker draws, named in Codumentor's words and taken from the closed set off · minimal · low · medium · high · xhigh · max (max sits strictly above xhigh). It is a product decision, not a read-out of the endpoint's vocabulary: a token the endpoint accepts but you do not want offered simply stays out of levels. Two optional tables carry the rest:
rung_map— what a rung outsidelevelscosts here, as a rung inside it. Several closed-scale rungs landing on one offered rung is the point.effort_values— the wire spelling of each offered rung, when it is not the rung's own name.
So a request travels closed scale → rung_map → levels → effort_values → wire. See thinking-modes-vocabulary.md and thinking-modes-rung-mapping.md.
reasoning:
control: chat_template_kwarg # none | always | chat_template_kwarg | effort_enum |
# budget_tokens | system_prompt | soft_switch
levels: [off, low, high] # the ladder this model offers — the picker's segments
default: high # used when a caller asks for no particular rung
effort_values: {high: xhigh} # what the endpoint hears for the `high` segment
rung_map: # optional; unset rungs clamp to the nearest offered one
medium: low # "medium is low on this model"
xhigh: high
max: high
echo: tool_turns # never | tool_turns | always — reasoning passback policy
- A rung outside
levelsis resolved onto it — throughrung_mapwhere you said so, otherwise clamped to the nearest offered rung — and never sent as-is (no 400s from the endpoint).offis a rung;control: alwaysdrops it, because such a model cannot stop reasoning. rung_mapmay be partial, and omitting it entirely is the same behaviour as before the field existed. Every value must be inlevels, and a key that is already inlevelsis a load error (it would shadow the identity and never fire).- A mode's
effort.allowednames closed-scale rungs, so it is tested through the map: a segment is offered when any rung that lands on it is allowed. On the record above,allowed: [medium, high, xhigh, max]offers bothlow(viamedium) andhigh. xhighandmaxare different rungs.extra-high/x-highare aliases ofxhigh;maximumis an alias ofmax. YAML reads the bare wordoffasfalse; both spellings work.- A record that is malformed — unknown
control, emptylevels, adefaultoutsidelevels,budget_tokenswith nobudget_range— fails at config load, not at the first turn. - Omitting
reasoningchanges nothing: a profile withthinking_enabled: truebehaves exactly as before (the chat-template switch, on/off), and one without it sends no reasoning field at all. Declaring a record is what turns the control on, and the record'sdefaultthen applies to every call that does not name a rung. - Per-control details (
budget_range,effort_values,system_prompts,effort_kwarg, …) are documented insrc/codumentor/config/reasoning.py; see alsodoc/core-dev/design/thinking-modes.md§7.
Known subagent tool names: subagent, explore, agentic_developer, web_retrieve, web_search, context_summarization.
Connections (models.connections):
A connection is one endpoint and the models it serves — a company OpenAI key exposing several models, an OpenRouter account, a gateway. Where a profile names one model, a connection lists many, and each listed model becomes a profile named <connection>/<model> that subagent_default, subagent_profiles, fallback_profile and a thinking mode's main/roles can name like any other.
models:
connections:
company-openai:
title: Company OpenAI
base_url: https://api.openai.com/v1/
api_key: ${OPENAI_API_KEY}
models: [gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna]
light_model: gpt-5.6-luna # omit = the provider catalog's default
# privacy: org_cloud # omit = derived from the host
temperature: 1.0 # any profile field here applies to every model
model_settings: # profile fields per model, replacing the above
gpt-5.6-sol: {context_window: 400000}
openrouter:
base_url: https://openrouter.ai/api/v1/
api_key: ${OPENROUTER_API_KEY}
models: [deepseek/deepseek-v4.1-flash]
subagent_profiles:
explore: company-openai/gpt-5.6-luna
web_retrieve: openrouter/deepseek/deepseek-v4.1-flash
- A reference is split at the first
/:openrouter/deepseek/deepseek-v4.1-flashis modeldeepseek/deepseek-v4.1-flashon connectionopenrouter. A name without/is amodels.profilesentry, as before. - A reference must name a model in that connection's
models:list; load cannot ask the endpoint what it serves. A connection with nomodels:generates no profiles. - Connection fields:
title,provider,base_url(required),api_key,codex_credentials_source,privacy,models,light_model,model_settings, plus any profile field (see the list above), which every model inherits.model_settings.<model>may set any profile field except the per-connection ones (base_url,provider,api_key,codex_credentials_source,codex_account_id). privacyisprivate,org_cloudorpersonal. Omitted, it is derived: a loopback, private-range, single-label,.internalor.localhost isprivate, any other hostorg_cloud, and a subscription (provider: openai-subscription) alwayspersonal. Declareorg_cloudfor a gateway on an internal host that forwards to a cloud API.- A connection id may not contain
/and may not bedeployment(reserved for theagent*model). It may equal amodels.profilesname — a name without/is always the profile — but a connection's<id>/<model>may not equal one. - Every problem is reported in one load error, not one per restart.
- A discovered (untrusted) project config can neither add nor change a connection — its whole
connections:block is ignored with a warning — for the same reason it cannot set a profile'sbase_url.
thinking_modes
Curated thinking modes — one named setting per conversation that decides which model answers, which model each background role uses, and how hard the assistant reasons. A mode is one altitude above models.profiles: it references profiles rather than replacing them. Delivered by the Thinking Modes plugin, which is enabled in the shipped default config.
Omitting the section ships two built-in modes — Fast and Slow, named after the two ways a person works a problem (why) — plus Custom, the user's own per-role mix of the two. They differ in deliberation alone and name no model, and the default is Slow, so nothing about your requests changes until someone picks Fast. The retired ids balanced and deep still resolve to slow, so stored selections and /mode deep keep working.
thinking_modes:
default: slow # mode for a user who has expressed no preference
modes:
fast:
title: Fast
description: Answers from what it can see and look up.
icon: bolt
cost: 1 # 1-3 relative badge
main: local-qwen # a name from models.profiles
effort: { default: "off" } # starts at off; the user can still turn it up
slow:
title: Slow
description: Plans before acting, strongest model for code changes.
cost: 3
main: cloud-strong
roles: { context_summarization: local-qwen, explore: local-qwen }
effort: { default: medium, allowed: [off, minimal, low, medium, high, xhigh] }
# a ceiling — never `max` on the cloud model
prompt: |
Before editing, write a short plan and verify it against the code.
effort is the deliberation dial: how long the assistant reasons before each step, not how many steps it takes. Read the rungs as which instruction the model is given rather than as a quantity of intelligence — medium is the model unsteered, not a midpoint between low and high. See the plugin page for the per-rung table.
| Field | Default | Description |
|---|---|---|
default | first declared mode | Mode a user with no preference lands on. |
modes.<id>.title | required | Label shown in the picker. |
modes.<id>.description | "" | One line under the title. |
modes.<id>.icon | unset | Icon token for the picker. |
modes.<id>.cost | 2 | Relative cost/speed badge, 1–3. |
modes.<id>.main | unset | Profile the main agent uses, and what unlisted roles inherit. Unset leaves model selection to models:. |
modes.<id>.roles | {} | role → profile name, overriding main for that role. |
modes.<id>.effort.default | unset | Rung the mode starts at. Unset = the resolved model's own default. |
modes.<id>.effort.allowed | unset | Rungs a user may select. Unset = every rung the model supports. Intersected with the model's reasoning capability, through its rung_map — a segment is offered when any rung that lands on it is allowed. |
modes.<id>.prompt | unset | Instruction block injected as a stable prefix note while this mode is active. |
- Declaring any mode replaces the built-in three.
customis reserved: it names the implicit mode a user's own Model Profiles assignments answer to, which keep working untouched. - Everything is cross-checked at config load (not at the first turn): a
main/rolesentry naming an undeclared profile, a role nobody answers to, aneffort.defaultoutsideeffort.allowed, or adefault:naming no mode all stop the config from loading, and the error lists every problem it found. - Roles you may name:
subagent,agentic_developer,agentic_memory_store,context_summarization,explore,goal,web_retrieve,web_search,auto_title,translation,voice_mediator, plus any role already named inmodels.subagent_profiles. effort.allowedis a ceiling, not a partition. Use it to keep a mode offmax; do not split the scale between two modes. A model that offers only two rungs then puts exactly one on each side of the split, and the picker becomes a radio group with a single selectable value — the bug rung-mapping fixed. A mode should change where the dial starts, not how far it reaches.- Background roles reason at the mode's
effort.default, resolved against their own model's capability — never at whatever rung the user is currently on. Turning a conversation up tomaxdoes not make the summariser reason atmax. - A mode selects models, effort and instructions. It never widens tool access; that stays whatever the access profile allows.
speech
Speech-to-text / text-to-speech service used by the read_aloud message action, the Voice Mode overlay, and Telegram voice replies. It is a sibling of models, not part of it, because the audio provider is usually a different endpoint than the chat-model gateway (e.g. a local vLLM box serves chat but not Whisper/TTS). Uses OpenAI-compatible audio endpoints (/v1/audio/transcriptions, /v1/audio/speech).
speech:
enabled: true
base_url: "https://api.openai.com/v1"
api_key: "${OPENAI_API_KEY}"
tts_model: "tts-1"
tts_voice: "alloy"
| Field | Default | Description |
|---|---|---|
enabled | false | Master switch. The web read_aloud action and Voice Mode overlay light up only when this is true and api_key resolves to a real value. |
base_url | https://api.openai.com/v1 | OpenAI-compatible audio endpoint base URL. |
api_key | null | Supports ${ENV_VAR} and ${secret:<name>}. Use an ${ENV_VAR} for read-aloud, Voice Mode, and the Telegram bot: a per-user ${secret:...} only resolves inside an agent turn, so it cannot drive those out-of-turn web calls. |
stt_model | whisper-1 | Speech-to-text model. |
stt_language | null | null = let the provider auto-detect the spoken language. |
stt_max_audio_bytes | 26214400 (25 MB) | Reject larger uploads before hitting the provider. |
tts_model | tts-1 | Text-to-speech model. |
tts_voice | alloy | TTS voice. |
tts_format | opus | opus → Ogg/Opus (native Telegram voice note). Also: mp3, aac, flac, wav, pcm. |
tts_speed | 1.0 | Playback speed multiplier. |
tts_max_input_chars | 4096 | Guard against synthesising an enormous reply. 0 disables it. |
read_timeout | 120.0 | HTTP read timeout (seconds). |
max_retries | 2 | Retry count for transient provider errors. |
See Plugins in Production → Speech for the operational checklist.
vector_db
Vector database configuration:
backend: Vector database backend to use (default: "chroma")path: Storage path for the vector database (default: "./data/vector_db")
cache
Embedding caching configuration:
embedding_enabled: Whether to enable embedding caching (default: true)embedding_db_path: SQLite database path for embedding cache (auto-determined if null)embedding_max_age_days: Maximum age for embedding cache entries (default: 365)
agent
Agent behavior configuration:
max_iterations: Maximum number of agent iterations (LLM calls with tool responses) per query (default: 10)storage_dir: Base directory for agent storage files (default: "./data/agent_storage")enable_subagents: Whether to register the genericsubagenttool (default: true). Set tofalseand the tool is not built on any surface — CLI, TUI, or web — and the subagent-strategy context note, which gates itself on that tool being present, disappears with it (together ~450 tokens of every request). Note the scope: this switch governs the generic subagent. Plugin-provided delegating tools (explore,agentic_developer, …) are controlled by whether their plugin is loaded, not by this flag.isolate_subagent_scratchpad: When false (default), subagents share the parent agent's conversation scratchpad so they can read and write shared state (e.g.files_accessed,repos_mentioned). When true, each subagent receives its own isolated scratchpad.attachment_staging: Bounds on the staging area under<storage_dir>/attachments/, where files the user attaches to the chat and files plugins fetch for the agent are kept so the agent can copy them into a repo (see Attachment staging). Nothing else expires this directory — it deliberately outlives session workspaces — so these are its only limits, and0means "no limit" for each:max_age_days: Conversation directories untouched for longer than this are deleted at startup (default: 30)max_files_per_conversation: Oldest-first eviction once a conversation exceeds this many staged files (default: 50)max_bytes_per_conversation: Same, by total size (default: 104857600 — 100 MB)max_file_bytes: Ceiling on a single file a plugin may stage on the agent's behalf, e.g. a mail attachment (default: 52428800 — 50 MB). Pasted images are capped separately by the upload transport at 5 MB.
tools
Tool-specific configuration:
tool_result_max_content_length: Maximum characters for any single tool output before it is automatically chunked and made pageable viaread_content(default: 30000). This universal limit applies to all tools — file reading, shell output, @-references, web retrieval, and any plugin output. Previously namedfile_reader_max_content_length.shell_output_max_size: Maximum size (in bytes) for shell command output before truncation (default: 10240)file_listing_max_size: Maximum size (in bytes) for file listings (default: 10240)shell_timeout: Timeout in seconds for shell command execution (default: 900). This is the ceiling; ashellcall that names no timeout gets 60s, and the tool schema states both so the agent can ask for more.shell_always_allow: Deprecated — seepermissionsbelow. It is now a shim that injects the operator allow ruleShell, which is not the old never-prompt behaviour: an allow rule cannot cover a command the analyser flags, socurl … | sh,sudo …, a recursive delete outside the workspace, a command substitution and unparseable input all ask. For the old meaning setpermissions.default_mode: bypassPermissions(default: false)shell_command_max_length: Maximum length in characters of a singleshellcommand (default: 32768). A heredoc script is a normal command; the previous 2,000-character cap rejected those and the agent's only recovery was to write the script to a file and run that — two extra round trips whose payload is then re-sent on every later request of the turn.read_file_line_number_interval: How often aread_fileresult carries a line number — every Nth line gets an<n>+ tab prefix and the rest arrive exactly as they are in the file;0numbers nothing (default: 1, i.e. every line). Numbering is not cheap: measured on a local Qwen-family model, 120 lines of Python cost 980 tokens bare, 1,251 numbered on every line and 1,036 numbered every fifth, because a number and its separator cost about two tokens where an average line of code costs eight. Those tokens buy orientation — which part of a file this is, where to resume paging, which line to cite — and an interval above 1 keeps most of that for a fraction of the price. The tool description tells the model which format it is getting, so raising this does not leave it guessing.search_concurrency: Maximum number of per-repository searches to run in parallel (default: 5)
permissions
The tool-approval gate's operator floor. Everything here is read-only over the API: no user setting, conversation mode or Auto verdict can widen it. See Permissions for the user-facing behaviour.
What reaches the gate. The full list, because two of these are easy to miss:
- shell commands (
shell_command); - the plugin tools that declare an approval: Gitea writes (
gitea.post_pr_comment,gitea.post_line_comment,gitea.submit_review), Redmine writes (redmine.api_request,redmine.create_issue,redmine.update_issue,redmine.add_note),database.execute,write_system_config,external_agent.invoke, and the Google Workspace writes (gmail.send,gmail.draft,gmail.modify,gmail.trash,gmail.label,calendar.event,calendar.event.create,calendar.event.update,calendar.event.delete,docs.create,docs.edit,sheets.create,sheets.edit); - file reads outside the workspace (
file_read), raised by the file tools themselves; sandbox_network—workspace_isolationasking whether the sandbox may share the host network, undernetwork: ask;- an MCP app's tool call (
MCPTool_<hash>), raised from the MCP-apps route; - a nested external agent's calls back into Codumentor's tools (
external_agent.mcp.<tool>), raised by the MCP bridge.
The last four do not pass through the agent's tool gate, so they carry no model-written reason — see reason_argument below.
File edits inside the workspace do not reach the gate: no file-editing tool requests a permission, so in a conversation no mode and no Write(…) rule stops one. Print mode is the exception, and it is a different mechanism — codumentor -p refuses the file-editing tools by name under default and plan (see default_mode below). The file_write permission type is reserved for the path-aware gating that has not shipped yet — until it does, what confines a write in a conversation is the workspace boundary (see repos_dir), not this block.
Hot-reload class: APPLY — the gate re-reads this block on every tool call, so an edit takes effect on the next one. reason_argument is the exception: it is baked into an agent's tool schema, so cached agents are flushed on reload.
permissions:
allow: [] # operator allow rules, same syntax as UI rules
deny: [] # operator deny rules — highest priority anywhere
default_mode: acceptEdits # what a conversation starts in
allow_bypass: true # false: "Always allow" unavailable to users
allow_auto: true # false: Auto unavailable to users
reason_argument: required # required | optional | off
auto: # Auto mode's triage
judge_role: permission_judge
judge_timeout_s: 12
include_reason: false
judge_arm_comparison_rate: 0 # 0-1: run both include_reason arms and log the pair
max_auto_allows_per_turn: 100
max_judge_calls_per_turn: 100
revert_to_ask_after:
consecutive_user_denials: 3
total_asks: 20
always_ask_types: [gmail.send, gitea.submit_review, write_system_config]
user_turns_window: 12
trajectory_window: 8
structural: # the deterministic command analyser
readonly_allow: [] # empty = the built-in read-only command set
max_complexity: 6
extra_escalate_patterns: []
project_code_allow_when_isolated: true
| Field | Type | Default | Description |
|---|---|---|---|
allow | list | [] | Operator allow rules (Shell(git status), Read(*.json)). Matched per simple command: every part of a compound command must match, and a command carrying an escalating flag can never be covered. |
deny | list | [] | Operator deny rules. The highest priority in the system — no user rule, mode or Auto verdict lifts one. A deny matching any part of a compound command denies the whole thing. |
default_mode | str | acceptEdits | What a conversation starts in when the user has no default of their own. One of default, acceptEdits, auto, plan (read-only), bypassPermissions. default and acceptEdits behave identically in a conversation — in-workspace file edits are ungated either way; they differ only in print mode (-p), where default also refuses the file-editing tools unless --allowed-tools names them. |
allow_bypass | bool | true | false removes "Always allow" from every user's choices, including a default they set earlier. |
allow_auto | bool | true | false removes Auto the same way. |
reason_argument | str | required | Whether permission-gated tools carry the approver-facing reason argument. optional keeps the field but drops it from required (for providers with strict schema validation); off injects nothing. Costs roughly 35 schema tokens per gated tool and 25 output tokens per gated call. |
##### permissions.reason_argument and what carries a reason
The reason is an argument on a tool schema, so it exists only for approvals that come from a tool call the model made: shell commands and every gated plugin tool. The four approvals raised elsewhere carry none, and both dialogs simply omit the line — an out-of-workspace file read (raised inside a read, by the file tool), sandbox_network (asked before the turn's first command runs), an MCP app's tool call, and a nested external agent's call back into Codumentor. A missing reason there is not a model that declined to explain itself.
permissions.auto
Only consulted in Auto mode (whether a user chose it, default_mode set it, or scheduler.permission_mode did).
| Field | Type | Default | Description |
|---|---|---|---|
judge_role | str | permission_judge | The model_role the tier-2 judge resolves through — map it in models.subagent_profiles to put the judge on a cheaper model. Must not collide with an agent role. Defaults to the agent model with thinking disabled. |
judge_timeout_s | float | 12.0 | A judge that has not answered by then prompts instead. |
include_reason | bool | false | Feed the agent's reason to the judge as a labelled, grounding-checked claim. Off by default: the evaluation in scripts/eval_permission_triage.py found the channel changed no verdict on 88 of 89 hostile items and, on the 89th, let a rationalising reason turn an ask into an allow. Turning it on gives the judge more to read at the cost of a persuasion channel; the switch exists so the trade-off can be re-measured as the corpus grows. |
judge_arm_comparison_rate | float | 0.0 | Fraction of judged calls (0-1) that ask the judge both ways — with the reason and without — in parallel, and answer with the safer of the two: the configured arm's verdict, downgraded to a prompt if the other arm wanted one. It can only ever turn a silent approval into a prompt, never the reverse. Each sampled call costs two judge calls instead of one and writes one line to the log (PERMISSION_JUDGE_ARMS {json}: both verdicts, both notes, agree, and the command and reason clipped to 240 characters). This exists because the setting above is decided one way per install, so no ordinary approval record can say what the other setting would have done; turn it up on a staging instance, or low (0.05) on a busy one, when you want that answer for your own traffic. Calls with no reason are not sampled, and a comparison is skipped when the turn's judge budget has room for only one call. |
max_auto_allows_per_turn | int | 100 | Silent approvals per turn; the next gated call prompts. |
max_judge_calls_per_turn | int | 100 | Judge calls per turn; beyond it, prompt. |
revert_to_ask_after.consecutive_user_denials | int | 3 | Denying this many Auto-produced prompts in a row switches the conversation back to Ask. A user allow resets the counter. |
revert_to_ask_after.total_asks | int | 20 | Auto asking this many times in one conversation switches it back to Ask. |
always_ask_types | list | [gmail.send, gitea.submit_review, write_system_config] | Permission types that always prompt, without consulting a judge — the irreversible and outward-facing ones. |
user_turns_window | int | 12 | How many of the user's own messages the judge sees (first turn plus the last N). |
trajectory_window | int | 8 | How many recent gated calls appear in the judge's trajectory digest. |
The judge never sees tool results, file contents, web pages, the agent's prose or thinking, conversation summaries, or repository instruction files. It cannot approve without quoting words the user actually typed, and any error, timeout or malformed answer prompts.
permissions.structural
The deterministic analyser feeds rule matching, the approval dialogs' badges and Auto's first tier, so a change here is visible in every mode. All three see the operator's knobs below — the gate re-derives a request's analysis from this block before it decides, so these apply with or without workspace_isolation loaded.
Two environments, one analyser. The analyser is given the conversation's sandbox facts where they are reachable, and one flag changes severity with them: network_egress is escalating when the network is reachable and informational when the sandbox has no network of its own. The rule engine cannot see the execution strategy — it is handed a request, not a conversation — so rule matching always analyses in the default environment: unsandboxed, networked, workspace boundary unknown. The consequence operators hit: Shell(git:*) never covers git push, in any sandbox configuration, while the badge on the dialog for that same command may still render in the quiet colour. That asymmetry is the conservative direction and is deliberate; both halves are documented in the user guide.
| Field | Type | Default | Description |
|---|---|---|---|
readonly_allow | list | [] | Overrides the built-in read-only command set. Empty means "use the built-in set" — an empty list is far more likely to be an accident than an intent, so use max_complexity or an escalate pattern to tighten instead. Adding a command here vouches for its whole option surface. The built-in set is narrowed per tool for the options that write a file, run a program or read something the command line never named (sort -o, rg --pre, fd -x, awk's program text, git remote add, …); a name you add gets no such narrowing, so any invocation of it counts as a read. Prefer a tool that cannot write or exec at all. |
max_complexity | int | 6 | Above this many commands + pipes + redirects + heredocs, the command is flagged long_command. |
extra_escalate_patterns | list | [] | Regexes matched against the raw command string. A hit escalates, which means no allow rule can cover the command and Auto can never approve it silently. An invalid regex is rejected at config load, not at gate time. |
project_code_allow_when_isolated | bool | true | Whether Auto's first tier may approve a command that runs project code (pytest, make, npm test) when the workspace is an isolated sandbox with no shared network. This is an Auto policy, not an analyser input: the analyser always reports the fact. |
Recommended postures
# Single-user developer install: quiet, still audited.
permissions:
default_mode: auto
# Multi-user team server: users may not switch the gate off, and the
# operator's deny rules are the floor.
permissions:
allow_bypass: false
deny:
- "Shell(sudo:*)"
# Read(...) covers reads outside the workspace only — a .env inside a
# repository is read without asking.
- "Read(.env)"
- "Read(.env.*)"
# Read-only demo instance.
permissions:
default_mode: plan
allow_bypass: false
allow_auto: false
api
API server configuration:
host: Host to bind to (default: "127.0.0.1")port: Port to bind to (default: 2638)reload: Whether to enable auto-reload for development (default: false)enable_web_ui/enable_openai_api: Which surfaces to expose (defaults:true/false). At least one must be enabled.external_url: The public URL browsers use, e.g."https://codumentor.example.com". Set this on every internet-facing deployment: besides composing OAuth callbacks, anhttps://value is what tells the app its sessions are HTTPS, so it marks the session cookieSecureand emits HSTS without depending on what the reverse proxy forwards.trust_forwarded: Believe the inboundX-Forwarded-*headers (default:false). They are ordinary client input unless a reverse proxy in front overwrites them, so they are ignored by default. Enable it only when a proxy sets them, and make that proxy strip any client-supplied copies. ControlsX-Forwarded-Proto(scheme),X-Forwarded-Prefix(the SPA's<base href>for path-prefix deployments) andX-Forwarded-For(audit-log client IP).cors_allow_origins: Origins allowed to make cross-origin browser requests (default:[]→ no CORS at all, which is correct when the SPA is served by this app). Listing exact origins enables credentialed cross-origin access;["*"]is accepted but forces credentials off.expose_api_docs: Serve/docs,/redocand/openapi.json(default:false). The schema enumerates every route and payload shape, so it stays off unless you want it.resume_interrupted_turns: After a restart, continue the conversations that were mid-turn when the process died (default:true). Each gets one new turn seeded with an automated note telling the agent it was cut off, and picks up from its own history. Turns the restart could not claim — a subagent turn, a scheduled run, a mediated exchange, or one still owned by another live process — are never resumed; with this off, none are, and every interrupted turn just stays marked "server restart".security_headers: Emit the security response headers from the app (default:true) — CSP,X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy, and HSTS on HTTPS.content_security_policy: Override the built-in CSP. The default allows same-origin scripts/styles plus inline styles and inline scripts (React inline styles and the markdown HTML-preview sandbox need them) while blocking off-origin script loads, off-originfetch/WebSocket, framing, plugins and<base>hijacking. Set""to send no CSP.hsts_max_age/hsts_include_subdomains/hsts_preload:Strict-Transport-Securitycomposition (defaults:31536000,true,false).hsts_max_age: 0omits the header. Only emitted for HTTPS requests.mcp_apps: Interactive MCP Apps (see below).app_preview: Sandboxed preview of agent-built static web apps (see below).rate_limit: Inbound rate limiting for the unauthenticated surface (see below).stream_flush_ms/stream_flush_bytes: Streamed-delta coalescing (see above).
##### Restarting the server from the Admin page
Administrators with admin:* (the same permission as Config Reload) can
restart a running API process from the web UI — no shell and no change to
systemd or Docker. See Process supervisor
for how the built-in supervisor brings the process back.
On the Admin hub (/admin), the Server section shows version,
edition, how long the process has been up, and whether restart is available.
Restart server… opens a confirmation dialog. The lead line is that
everyone using the server is disconnected for a few seconds. The dialog lists
active work and what happens to it: agent turns resume after the new
boot (when resume_interrupted_turns is on); **in-flight ingestion and
scheduled / triggered runs are aborted and marked failed**, not resumed;
approval waits survive. There is no drain mode — Restart now shuts down
immediately.
If restart is unavailable (for example the server was started with
--no-supervisor, or the home is ephemeral), the button is disabled and the
panel explains why.
On Admin → Config Reload (/admin/config), a "restart required" banner
after a hot-reload that could not apply every change includes Restart now,
which opens the same dialog.
After a restart is accepted, every connected client shows a non-dismissable
Codumentor is restarting… overlay and polls /health until a new boot id
appears, then reconnects.
api.mcp_apps
Interactive MCP Apps are enabled
by default only after the deployment supplies a valid sandbox origin. With
no sandbox_origin, or with an unsafe one, Codumentor stays text-only and does
not advertise the Apps extension to MCP servers.
api:
external_url: "https://codumentor.example.com"
mcp_apps:
sandbox_origin: "https://mcp-app-sandbox.example.net"
enabled(default:true): emergency/compatibility kill switch.falsesuppresses capability advertisement and all app endpoints.sandbox_origin: dedicated origin for the untrusted proxy shell. Production requires HTTPS and an origin different fromexternal_url. Two loopback HTTP origins are allowed for development.input_max_bytes/result_max_bytes/resource_max_bytes/model_context_max_bytes/total_max_bytes: durable and bridge payload ceilings (defaults: 256 KiB, 2 MiB, 2 MiB, 64 KiB, and 3 MiB).
The sandbox hostname can reverse-proxy to the Codumentor process. The process
serves only /mcp-app-sandbox.html on that virtual host and returns 404 for API
and SPA routes. Do not attach a broad Domain= cookie to a parent domain shared
by the host and sandbox. If content_security_policy is custom, its
frame-src must explicitly include the normalized sandbox origin.
Roll out by configuring the origin on one instance, confirming the log does not
report an MCP Apps availability reason, and watching
GET /ui/admin/mcp-apps-metrics. Useful signals are lifecycle failed versus
initialized, resource failures/cache hits, RPC outcomes and p95 latency, and
security rejections. The endpoint requires admin:*; POST the corresponding
/ui/admin/mcp-apps-metrics/reset endpoint before a compatibility exercise.
If failures rise, set api.mcp_apps.enabled: false; textual tool results remain
available and existing conversation data needs no migration.
api.app_preview
Runs an agent-built static web app in a sandboxed iframe, so *"build me a small
web app"* ends with something the user can click rather than a file listing.
Same shape and same reasoning as api.mcp_apps above — one more DNS name
pointing at the same process — and deliberately so: an operator should have to
learn this mechanism once.
api:
external_url: "https://codumentor.example.com"
app_preview:
sandbox_origin: "https://preview.example.net"
enabled(default:true): kill switch.falseremoves the preview endpoints, the/previewcommand and thepreview_apptool entirely.sandbox_origin: dedicated origin for the previewed app. Production requires HTTPS and an origin different fromexternal_url; two loopback HTTP origins are allowed for development, and the whole127.0.0.0/8block counts — soexternal_url: http://127.0.0.1:2638plussandbox_origin: http://127.0.0.2:2638gives a dev box a working second origin with no DNS at all.session_ttl_minutes(default:60): capability lifetime, sliding on use, hard-capped at 8 hours.max_file_bytes(default: 25 MiB) /max_session_bytes(default: 200 MiB) /max_sessions_per_user(default:8, LRU-evicted).allow_shared_sandbox_origin(default:false): permit pointing this at the MCP Apps origin while MCP Apps are also enabled. Refused by default, because a concurrently-open MCP App can reach a sibling preview frame by index, become same-origin with it and read the preview's session id out of itslocation. Sharing when MCP Apps are disabled needs no flag — the gate works that out for itself.
Without a valid sandbox_origin the feature does not disappear; it runs in
the degraded tier, served from the main origin inside an allow-scripts-only
frame. Relative assets load, so a multi-file build renders; localStorage and
the app's own same-origin fetch() do not, because an opaque origin has no
storage and is same-origin with nothing. (history.pushState() to a same-URL
path does work there — measured, contrary to what the design doc predicted.)
The overlay says so in one line.
Root-absolute asset paths (<script src="/assets/index.js">, the Vite default)
also only resolve on the app tier; build with a relative base
(Vite's base: './') or set sandbox_origin.
The sandbox hostname can reverse-proxy to the Codumentor process. On that
virtual host the process serves only /_app-preview/* and returns 404 for API
and SPA routes — and, symmetrically, /_app-preview/* returns 404 on the
primary host once a valid sandbox origin exists, so the second origin is not
merely optional decoration. As with MCP Apps: do not attach a broad Domain=
cookie to a parent domain shared by the host and sandbox, and if
content_security_policy is custom its frame-src must explicitly include the
normalized preview origin or the feature drops to degraded.
A preview is read-only, confined to one directory subtree, re-authorized
against the minting user's access profile on every request, and served with
connect-src 'self' — the previewed app can fetch its own files and nothing
else. The same policy blocks every off-origin subresource, so an app built
against a CDN will not load its script, stylesheet or font: that is the point
(it is what stops repo bytes leaving through the app), and the overlay's console
badge names the refused requests rather than showing a blank page.
One exposure is worth knowing, because no Content-Security-Policy closes it:
a document can always navigate itself away, so a previewed app could put
data in a URL and go there. In the overlay the page framing it refuses that
navigation — which is why a custom content_security_policy whose frame-src
is broad (*, a bare https:, or a wildcard host) drops this feature to
degraded rather than being accepted. The overlay's Open in new tab opens
the preview with no framing parent and therefore without that refusal; it is
labelled accordingly, and a deployment that does not want it can turn the
feature off or run without a sandbox_origin (the degraded tier hides the
button). See doc/core-dev/design/spa-preview.md §7 for the threat model.
api.rate_limit
Budgets are per client IP per minute; 0 drops a rule. Only the paths listed
are limited — there is deliberately no blanket cap, which would throttle the
SSE stream and the UI's own polling. A refused request gets 429 with
Retry-After.
enabled: Master switch (default:true). Turning it off logs a warning and accepts unlimited login attempts.login_per_minute:POST /auth/login(default:10).refresh_per_minute:POST /auth/refresh(default:30).logout_per_minute:POST /auth/logout(default:30) — unauthenticated by design, so that a tab whose access token expired can still log out, and it writes to the token deny-list.webhook_per_minute:/ops/webhook/*(default:120) — a monorepo can burst on a force-push.ops_per_minute: the rest of/ops/*, e.g. ingest (default:30).share_per_minute:/ui/share/*(default:240) — one share view fans out into a file request per image.preview_per_minute:/_app-preview/*(default:600) — unauthenticated by construction (the capability is in the path), and one page load of a bundled SPA is dozens of asset requests, so this is an order of magnitude above the others. It bounds a flood; it is not meant to pace a preview.login_failure_threshold/login_lockout_seconds/login_lockout_max_seconds: Consecutive failed logins tolerated before a lockout starts, and its duration (defaults:5,60,900). Each further failure doubles the wait up to the max; a success clears it. Applied per username and per client IP, and checked before the auth provider is called, so a guessing run does not keep costing a live LDAP bind. A locked-out username answers429whether or not the account exists, so this is not an account-existence oracle.max_tracked_keys: Ceiling on tracked addresses/usernames (default:20000), so the counters cannot themselves be exhausted by an attacker rotating source addresses.
Two caveats worth knowing. A shared egress IP (corporate NAT) shares a budget —
raise the share limits first if that bites, not the login limit, since the
failure backoff is what actually stops guessing. And the counters are
in-process: a multi-worker deployment divides every budget by the worker count.
ui
Web UI customization:
welcome_title: Title shown on the conversation opening page. Can be a plain string (used for all languages) or a locale map.welcome_description: Description shown below the title. Same format aswelcome_title.disabled_extensions: List of plugin IDs whose UI extensions should be hidden (default:[]).
When welcome_title or welcome_description are not set, the built-in translated defaults are used.
Plain string (same text for every language):
ui:
welcome_title: "Welcome to ACME CodeBot"
welcome_description: "Ask questions about our internal services and libraries."
Per-locale map (i18n):
ui:
welcome_title:
en: "Welcome to ACME CodeBot"
hu: "Üdvözöl az ACME CodeBot"
welcome_description:
en: "Ask questions about our internal services and libraries."
hu: "Kérdezz a belső szolgáltatásainkról és könyvtárainkról."
You can mix the two styles — for example a plain welcome_title with a locale-mapped welcome_description.
auth
Authentication configuration for API access:
provider: Authentication provider to use. A generated config sets"local"with one env-var-password account; the field's own fallback when noauthblock exists at all is"none"."local": Username/password users defined directly in config (the shipped default)"gitea": Use Gitea personal access tokens for authentication"ldap": Use LDAP/Active Directory for authentication"none": No authentication required. Refused on any bind address other than loopback unlessallow_unauthenticated_network_accessis set — see Authentication- (
"test"exists for the test suite only — never use it in a real deployment) ui_provider/api_provider: Optional per-surface overrides.ui_providertakes precedence overproviderfor the UI;api_provideroverrides the API when set, otherwise inheritsui_provider. See Authentication.jwt_secret: Deployment-wide secret that signs persistent login sessions and seeds at-rest encryption for theuser_secrets/model_profiles/oauthstores. Read it from an env var ("${CODUMENTOR_JWT_SECRET}"). A user-home install with that variable unset generates$CODUMENTOR_HOME/jwt_secreton first start and uses it immediately. A project config does not: the placeholder is dropped with a loud error, sessions become ephemeral, and those stores are written unencrypted. See Authentication → jwt_secret.issuer:issclaim minted into this instance's tokens and required of every token it validates (default: unset = not checked). Set a distinct value per instance when several instances share ajwt_secret— on one host they also share a cookie jar, since cookies are not scoped by port, and a sibling's session would otherwise authenticate here and then silently degrade to the restricted access profile. Enabling it logs everyone out once. See Authentication → issuer.none.default_roles: Roles granted to the anonymous user whenprovider: none(default:["user"]).cookie_secure: Force the session cookie'sSecureflag (default: unset = auto). Auto meansSecureis set when the request arrived over HTTPS orapi.external_urlis anhttps://URL. Settrueto require HTTPS unconditionally;falseonly for a deliberately plain-HTTP deployment.cookie_samesite:SameSitepolicy for the session cookie (default:"lax").laxis what keeps cross-site requests from carrying the session — the app has no CSRF token, so do not move to"none"without one.
A browser login sets two cookies, both
HttpOnly, and both covered by the
settings above:codumentor_session(the access token) and
codumentor_refresh(the refresh token,SameSite=strictand scoped to
Path=/auth/refresh). Neither token is ever returned in a response body to
the browser, so no script — including an injected one — can read or exfiltrate
a session. Programmatic clients are unaffected: they still get tokens in the
body ofPOST /auth/loginand send them back as
Authorization: Bearer <jwt>.
The configured provider is the only one that can serve an interactive
login:POST /auth/loginrefuses a caller-suppliedprovider, and
GET /auth/providerslists only that provider. A provider named in config
whose configuration block is missing is a startup error rather than a silent
fallback to unauthenticated access.
Sessions are revocable. Logging out invalidates both tokens server-side,
refreshing invalidates the token it consumed, and deactivating a user (or
taking a role away) invalidates everything they hold — so those are real events
rather than client-side gestures, and a copied token stops working. Revocations
live indata/auth/revoked_tokens.dbso a restart cannot resurrect a
logged-out session; entries are purged automatically once the token they name
has expired anyway. No configuration; see
doc/security/session-credentials.md§6.
See the Authentication guide for the full per-provider reference.
Gitea Provider
gitea.base_url: Base URL for Gitea instance (required when provider is "gitea")gitea.token_cache_ttl_minutes: Token validation cache TTL in minutes (default: 30)- Caches successful validations to reduce Gitea API calls
- Set to
0to disable caching - Recommended range: 15-120 minutes for production use
- Security Note: Only valid tokens are cached; invalid tokens are never cached
Local Provider
A fixed set of username/password users defined in config. The simplest way to give a small team real logins without Gitea or LDAP:
auth:
provider: local
jwt_secret: "${CODUMENTOR_JWT_SECRET}" # keep persistent sessions across restarts
local:
users:
- username: admin
password: "${CODUMENTOR_ADMIN_PASSWORD}" # plaintext or pbkdf2_sha256$ hash
email: admin@example.com
roles: ["admin", "user", "power_user"]
- username: dev
password: "${CODUMENTOR_DEV_PASSWORD}"
roles: ["user"]
Each entry takes username (required, unique), password (required — plaintext or a pbkdf2_sha256$... hash, ${ENV_VAR} substitution supported), and optional email, display_name, and roles (default ["user"]). Generate a hash with python -c "from codumentor.auth.providers.local import hash_password; print(hash_password('s3cret'))". See Authentication → Local for details.
LDAP Provider
For enterprise environments using LDAP or Active Directory:
auth:
provider: ldap
ldap:
# Connection settings (required)
server_url: "ldaps://ldap.example.com" # or ldap:// for non-SSL
bind_dn: "cn=service,dc=example,dc=com"
bind_password: "${LDAP_BIND_PASSWORD}"
base_dn: "dc=example,dc=com"
# Server type: "active_directory", "openldap", or "generic"
server_type: "openldap"
# Security: "ldaps" (SSL), "starttls", or "none"
security_mode: "ldaps"
# User search settings
user_search_filter: "(&(objectClass=inetOrgPerson)(uid={username}))"
user_search_base: "ou=users,dc=example,dc=com" # defaults to base_dn
username_attribute: "uid" # "sAMAccountName" for AD
email_attribute: "mail"
display_name_attribute: "displayName"
# Group settings (for role mapping)
group_search_base: "ou=groups,dc=example,dc=com"
group_member_attribute: "memberOf"
# Role mapping: LDAP groups → Codumentor roles
group_role_mapping:
"cn=admins,ou=groups,dc=example,dc=com": ["admin"]
"cn=developers,ou=groups,dc=example,dc=com": ["developer"]
default_roles: ["user"]
# Optional: require membership in a specific group
require_group: "cn=codumentor-users,ou=groups,dc=example,dc=com"
# SSL certificate validation
validate_cert: true
ca_cert_file: "/path/to/ca-cert.pem" # optional
Active Directory defaults (when server_type: active_directory):
user_search_filter:(&(objectClass=user)(sAMAccountName={username}))username_attribute:sAMAccountNamegroup_search_filter: Uses AD's nested group resolution
OpenLDAP defaults (when server_type: openldap):
user_search_filter:(&(objectClass=inetOrgPerson)(uid={username}))username_attribute:uid- Nested group resolution disabled by default
Multiple LDAP directories. Replace the ldap: block with a list under
ldap_directories: to authenticate against several independent directories.
Each entry takes the full set of LDAP options shown above plus a unique
name. On login the directories are tried in declared order; the first
successful bind wins. See Authentication
for the routing rules, identity behaviour, and audit-log labelling.
auth:
provider: ldap
ldap_directories:
- name: corp
server_url: "ldaps://corp.example.com"
# ... full LDAP options ...
- name: contractors
server_url: "ldaps://contractors.example.com"
# ... full LDAP options ...
prompt
Prompt/template system configuration (see Prompt System for the full reference):
template_dir: Directory containing prompt template files (default: "prompts"). Override at runtime with--prompt-dir.system_prompt_template: System prompt template file (default: "system_prompt.jinja2")max_iterations_notification_template: Template for max iterations notification (default: "max_iterations_notification.jinja2")custom_instructions: Additional custom instructions to include in promptsdebug_mode: Whether to enable debug mode for prompt templates (default: false)show_tool_calls: Whether to show tool call descriptions in the output stream for both main and subagents (default: true). When true, tool calls will be shown to the user on the API.
tools
Tool-specific configuration:
shell_output_max_size: Maximum size (in bytes) for shell command output (default: 10240)file_listing_max_size: Maximum size (in bytes) for file listings (default: 10240)shell_timeout: Timeout in seconds for shell command execution (default: 900)shell_always_allow: Deprecated, a shim for the operator allow ruleShell— seepermissions(default: false)disable_vector_search: Whether to disable vector database search functionality (default: false)search_concurrency: Maximum number of per-repository searches to run in parallel (default: 5)
When disable_vector_search is set to true, the agent will not use the vector database for semantic search, relying instead on other methods like file reading and shell commands to find information.
task engine
Controls the task engine — the component that fires recurring and one-off jobs onto the job queue (scheduled agent runs, periodic reports, ingestion refresh, etc.).
The task engine shares its SQLite file with the job queue (jobs.sqlite3 by default, overridable via CODUMENTOR_JOBS_DB_PATH). WAL is enabled automatically.
tasks:
enabled: true
idle_poll_interval: 30.0 # max seconds between loop ticks when idle
disable_after_failures: 5 # consecutive infra failures before auto-disable
max_catchup_age: 86400.0 # 24h — skip fires older than this
max_tasks_per_user: 1000
default_timezone: "UTC"
max_chain_depth: 3 # event triggers: how deep a causal chain may go
max_fires_per_minute: 60 # event triggers: instance-wide circuit breaker
event_retention_days: 30 # event triggers: how long the event ledger keeps a row
allow_agent_created: true # may an agent create tasks from a conversation?
# default_run_token_budget: 200000 # per-run token cap; unset = unlimited
# default_webhook_run_token_budget: 100000 # same, for webhook-fired runs
# max_run_token_budget: 2000000 # ceiling on any per-run budget
permission_mode: bypassPermissions # what an unattended turn may do
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Master switch. When false, the task engine loop never starts and schedule_* operations will fail. Existing tasks remain in the DB. |
idle_poll_interval | float | 30.0 | Maximum seconds the loop sleeps between ticks when no fires are imminent. Lowering this costs a bit more DB I/O; raising it delays reaction to newly-created tasks up to this bound. |
disable_after_failures | int | 5 | After this many consecutive infrastructure failures (e.g. database is locked on enqueue), the task is auto-disabled and its status becomes disabled. Handler exceptions do not count — they mark the fired job failed, not the task. |
max_catchup_age | float | 86400.0 | If the task engine has been offline and a scheduled fire is older than this many seconds, skip it silently and jump to the next occurrence. Prevents firing a week-long backlog of stale runs after an outage. |
max_tasks_per_user | int | 1000 | Cap on active+paused tasks per owner_user_id. Protects against unbounded growth if users can create tasks interactively. |
default_timezone | str | "UTC" | Default IANA timezone for tasks that don't specify one. |
max_chain_depth | int | 3 | Event triggers. How many hops a causal chain of events may fire before the router refuses. An event arriving from outside (a webhook, an ingestion) is depth 0, and events a run emits are depth+1, so this bounds any cycle at this many fires whatever its shape — A fires B fires A included. A refused fire records a skipped run row with reason chain_depth and logs a WARNING carrying the chain_id, so the whole chain is one list_events(chain_id=...) query. A task may narrow this for itself; it cannot widen it. |
max_fires_per_minute | int | 60 | Event triggers. Instance-wide circuit breaker across all tasks. Tripping it is logged at ERROR, not WARNING: at this rate something is wrong, not busy. Coalesced events do not each count — a burst that merges into one pending run is charged once. 0 (or negative) disables the breaker; to stop event tasks firing at all, pause them or set enabled: false. |
event_retention_days | int | 30 | Event triggers. How long the event ledger keeps an event row, purged by the task_tools maintenance sweep (the same daily loop that ages out run history). The capped, debug-only raw webhook body is nulled far sooner — 48h — because it is the bulky part and stops being interesting first. 0 keeps events forever. |
allow_agent_created | bool | true | Whether the agent-callable create_task tool may create tasks. Set false to make automation an operator-only act: the tool stays visible and refuses with a reason (so the model is told no rather than inventing a workaround), the task engine keeps running, and user- or config-created tasks are untouched. Independent of the trust boundary — an agent may never create a task firing on a webhook-sourced event type (repo., scm., webhook.*) whatever this says, because an outside party would then influence both whether an unattended turn runs and what text is in its prompt. See triggers §12.1. |
default_run_token_budget | int | unset | Token budget a scheduled/event run gets when its task (or goal worker) sets none. Counts every token sent to or received from the model, including cached prompt tokens. A run that spends it stops at the next step boundary and is recorded failed with error_kind: token_budget (counts toward a goal worker's auto-disable like a timeout). Unset = unlimited. Minimum 1000. |
default_webhook_run_token_budget | int | unset | Same, for runs fired by a webhook-sourced event (repo., scm., webhook.*); takes precedence over default_run_token_budget for those runs. Precedence for one run: the task's own token_budget > this (webhook-fired runs) > default_run_token_budget > unlimited. |
max_run_token_budget | int | unset | Ceiling applied to whichever budget wins above, agent- and user-set ones included (like the 6h ceiling on timeout_seconds). Unset = no ceiling; it never turns an unlimited run into a limited one. Minimum 1000, like the other two. |
permission_mode | str | "bypassPermissions" | The approvals mode an unattended turn runs in — a scheduled run or a Gitea webhook turn. See below. |
Unattended approvals. A scheduled or webhook turn has nobody to answer a
permission prompt, so it does not use the conversation's or the user's mode; it
uses this one. The default, bypassPermissions, is the behaviour Codumentor has
always had: allow everything the operator's deny rules do not refuse.
auto runs the same triage an interactive Auto conversation gets — the
deterministic analyser, then the judge — and because there is nobody to ask,
a call that would have prompted is denied instead. The refusal names the
command, the analyser's flags, the judge's note and the agent's own reason, and
goes three places: the tool result the model reads (so the run's final message
can say what it could not do), the transcript card, and the durable audit row
(decided_by = unattended). The model is told, keeps going, and the run ends
with a report rather than a hang. plan makes unattended turns read-only.
default and acceptEdits degrade the same way as auto but without the
triage, which makes them strict-deny modes rather than useful ones.
Per-run token budgets. A budget bounds one unattended run's spend the way
timeout_seconds bounds its wall time. It is set per task (create_task /
update_task token_budget), per goal worker (token_budget in its
definition.yaml, update_worker, the worker edit form), per declared task
(run.token_budget), or falls back to the defaults above. The effective value
is resolved once when the run starts and stored on the run row, so run history
shows the limit each run actually had. What is counted and when it stops:
- Agent tokens only. Every LLM call of the run's main loop and of its subagents (they share the run's ledger), plus the tokens an external agent (
external_agenttools) reports for its call. Auxiliary calls are not counted: auto title, turn summaries, memory recall, the Auto-mode and script-filter judges, context compaction, the merge resolver. - Cached input counts fully. The unit is total input (cache reads and writes included) plus output — it is a runaway guard, not a bill. A call whose provider reports no usage is charged its estimate.
- Stops at a step boundary. The check runs at the top of each agent-loop iteration, so a run can exceed its budget by up to one iteration (one LLM call plus the tool batch it started; each parallel subagent can add one call; an external agent's whole call is charged when it returns). At 80 % the main agent gets a one-time "save your work and conclude" note. The stop ends the turn cleanly (no extra summary call) with a notice in the transcript.
- Outcome.
status: failed,error_kind: token_budget, errorStopped at its token budget (N of M tokens). Like a timeout it counts towarddisable_after_failures(a goal worker's consecutive-failure auto-disable) and does not satisfy atask.run_finished … status eq successchain. - Approvals. A run whose turn suspends on an approval keeps its budget across the resume: the run stays
runningand is finished by the resumed turn. An approval left unanswered past thetask_toolsplugin'sparked_run_ttl_seconds(default 7 days) ends the runfailed/stale. - Restarts. The ledger is in memory. A server restart loses the tokens a run spent while it was still running. A run waiting on an approval keeps the tokens it had spent so far, but cannot be resumed after a restart: it ends
failed/stale, and answering its approval later does not continue the turn.
Every run (budgeted or not) records input, cached, cache-write, output and
total tokens on its run row; the run card and the goals run panel show them,
with the budget and % used. Cost reads unknown for a model without a price
(never $0); a total mixing priced and unpriced models is a lower bound.
Design: run-token-budgets.
Event triggers. The three max_chain_depth / max_fires_per_minute / event_retention_days fields govern event-driven tasks (on: repo.ingested, on: task.run_finished, …) rather than clock-driven ones; they are inert on an instance with no event tasks. Per-task caps (debounce_seconds, max_delay_seconds, max_fires_per_hour, allow_self) live on the task itself. See triggers §11.
tasks.declared — operator-declared event tasks
A list of tasks the config file owns. They are reconciled against the running
task engine at startup and on every tasks: reload: entries are created,
updated and cancelled to match the list, and nothing a user or an agent created
is touched.
This is also the only surface allowed to declare a task that fires on a
webhook-sourced event type (repo., scm., webhook.*). Such a task lets an
outside party decide both whether an unattended, tool-auto-approving turn runs
and what text is in its prompt, which is an operator decision rather than a
conversational one — so it is refused to an agent whatever allow_agent_created
says, and it is refused here without an access_profile. See
triggers §12.1 and §12.4.
Quote the
on:key. PyYAML implements YAML 1.1, whose booleans are
y|yes|n|no|true|false|on|off, so a bareon:parses as the booleanTrue
and the entry never sees the field it is named after. Write"on":. The
entry is refused with a message spelling out the fix rather than quietly
repaired — accepting the boolean key would makeon:work here and nowhere
else you might expect symmetry.
tasks:
declared:
- name: review-on-pr
"on": scm.pull_request # note the quotes
scope: my-repo # optional: the event's scope_key
when:
- [action, in, [opened, synchronize]]
mode: per_event
on_overlap: queue
access_profile: pr-reviewer
run_as: review-bot # optional: a real account to run as
description: Review a pull request when it opens or gets new commits.
run:
prompt: |
Review pull request #${event.pr.number} in ${event.repo}.
Title: ${event.pr.title}
timeout_seconds: 1800
| Field | Required | Description |
|---|---|---|
name | yes | The reconciliation key. See the semantics below. Also shown on the run card. |
"on" | yes | The event type to fire on. A typo is refused at load: for plugin.* types the plugin's declared emits vocabulary is checked too. Use the list_event_types tool to see the vocabulary and what each event carries. |
run.prompt | yes | The turn's instruction text. May carry ${event.<path>} placeholders. |
run.timeout_seconds | no | Wall-clock cap on the fired job. Omitted leaves the queue's own per-queue default in force. |
run.token_budget | no | Token cap on the fired run (see default_run_token_budget). Omitted falls back to default_webhook_run_token_budget / default_run_token_budget; clamped to max_run_token_budget. |
access_profile | conditional | Required for a webhook-sourced on:; a config error without one. Never event-chosen. |
run_as | no | The run identity: it becomes job_args["user_id"], which is the account the turn executes as and what per-user secrets are keyed on — so a declared task that must call a tool holding a personal access token needs it. Read only from this file and never derived from an event, so an outside party cannot choose the identity its trigger runs as. A system:* principal is refused (nothing can log in as one, so there is no credential to borrow), and the id is vetted against the same creation ceiling a task creation faces. Omitted, the run executes as system:config, which holds no per-user secrets. The task row's owner_user_id stays system:config either way — that is what reconciliation uses to tell its own rows from a user's. |
scope | no | Cheap indexed narrowing — a repo name, a goal id. |
when | no | [path, op, value] clauses, ANDed, evaluated over the normalized payload. "Either/or" is spelled as two entries. |
mode | no | coalesce (default) merges a burst into one run; per_event runs once per event. |
enabled | no | false reconciles the task to cancelled while leaving the lines in the file to document what it was — the same terminal state as deleting the entry, and the entry is not validated either (turning a task off must not first require fixing it). |
description | no | Free text, stored on the row. |
metadata | no | Extra task metadata. Keys carrying run identity, job routing or engine-stamped provenance are refused, not stripped — a declaration free to write task_id would be a forgeable audit trail. access_profile and the timeout have their own fields. |
bind | no | Explicit ${event.<path>} → job_args bindings. Rarely needed: placeholders in run.prompt are compiled into this automatically. |
job_type | no | Defaults to agent_task_run; repo_ingest is the only other accepted value. |
target_queue | no | Defaults to agent_runs. |
on_overlap | no | skip (default), queue or cancel_previous. |
debounce_seconds | no | coalesce window. Default 30.0; 0 fires on the next loop tick. |
max_delay_seconds | no | Cap on debounce extension, measured from the first event of the burst. Default 300.0, and never below debounce_seconds. |
max_in_flight | no | How many of this task's jobs may be pending or running at once. Default 3. |
max_fires_per_hour | no | Per-task storm cap. Default 12. Three times the cap in routing attempts within an hour auto-pauses the task. |
allow_self | no | Whether an event caused by this very task's own run may fire it again. Default false. |
max_chain_depth | no | Narrows tasks.max_chain_depth for this task. It can never widen it. |
An unknown key in an entry is refused, not ignored: a mistyped line in a
declaration that decides whether an unattended, tool-auto-approving turn runs
must not be silently dropped. Two entries sharing a name are refused for the
same reason rather than resolved last-one-wins — the name is the key, so one of
them would silently not exist.
Reconciliation semantics. name is the key, and everything follows from
that:
| You do | Reconciliation does |
|---|---|
| Add an entry | Creates the row. |
| Change an entry | Updates the row in place, keeping its id and run history. Changing the firing spec discards any pending arming (it was matched against a spec that no longer applies); changing job_type or target_queue cannot be updated in place and is a delete plus a create. |
Remove an entry, or set enabled: false | Cancels the row — it is not deleted. The history stays visible, and an in-flight run is left to finish exactly as your own cancel would leave it. |
| Rename an entry | Delete plus create. There is no rename detection and deliberately none: two entries that swapped names could only be matched by guessing. The old name's row is cancelled and the new name gets a fresh one, with no history carried over. |
| Re-add a name you removed | Deletes the cancelled tombstone and creates fresh, so a remove/re-add cycle cannot accumulate one row per cycle. |
Pause a declared task, or let disable_after_failures disable it | Nothing. Reconciliation decides a task's existence and shape, never its activity — a paused declared task stays paused across edits and restarts, and is resumed with resume_task. |
A bad entry costs different things at different times. At startup each
entry is compiled independently: one that fails is logged at ERROR and skipped,
and its existing row is left alone rather than cancelled, so a typo
introduced while editing one task cannot delete a different one. On reload
a tasks: edit is all-or-nothing: any compile error rolls the whole
section back to the last list that validated, re-reconciles against that, and
reports the section as failed.
Reloading. tasks: is REBUILD-classified, so editing declared: takes
effect on the next reload without a restart (SIGHUP, the admin endpoint, or
the auto_reload watcher). The caps in the same section are read live off the
config object. tasks.enabled is the exception — it is read once at startup,
and a reload that changes it raises a "restart required" caveat.
What ships by default. The default config carries three entries:
ingest-on-push (the built-in ingestion task — see
Webhooks), and gitea-review-on-pr /
gitea-review-on-request, both enabled: false because a review run posts to a
public pull request unattended. Turning those two on means adding the
gitea_pr plugin, defining the pr-reviewer access profile they name,
creating the gitea-review-bot account their run_as: names (with a
gitea_api_token user secret on it), and setting the plugin's
auto_post_on_review_comment: true — until that last one an unattended run
holds no gitea_* write tool and posts nothing, which makes it a working
kill-switch even with the entries enabled; see
gitea_pr. The minimal, marketing and
root flavor configs carry ingest-on-push alone; investigate declares none
and turns the task engine off entirely.
Routing. By default, scheduled fires land on the shared agent_runs job queue. Plugins that need per-user isolation can pass a per-user queue name at task creation (target_queue=f"agent_runs_{user_id}"). Be aware that each distinct queue name spawns its own processor task — don't multiply them casually.
Disabling at runtime. Set enabled: false and restart. Tasks persist in the DB and resume when re-enabled. To stop a single task without touching config, use task_engine.pause(id) or task_engine.cancel(id) from code.
See the task engine guide for the plugin-developer view and firing semantics.
webhooks
Push-notification webhook endpoint — lets Gitea/GitHub trigger ingestion automatically. See Webhooks for full setup.
enabled: Master switch. Whenfalse(default) the endpoint returns 404.secrets: List of accepted shared secrets. The request is accepted if any entry matches. Supports environment substitution (${VAR_NAME}) and zero-downtime rotation (add new, remove old). Leaving this empty whileenabled: truereturns 503.
webhooks:
enabled: true
secrets:
- "${CODUMENTOR_WEBHOOK_SECRET}"
- "${CODUMENTOR_WEBHOOK_SECRET_PREVIOUS}"
logging
Logging configuration:
level: Base log level (default: "INFO")file: Log file path (default: "./logs/codumentor.log")logger_levels: Logger-specific log levels that override the base levelcategory_levels: Backward compatibility for logger levels (maps to logger_levels)console: Optional boolean. If set, controls whether log lines are also written to standard error in every mode (API server, CLI, TUI). If omitted, Codumentor chooses a default: the API process logs to stderr as well as the log file; CLI and TUI log only to the file so stdout/stderr stay free for command output and the terminal UI. Setconsole: trueto mirror API-style logging during local CLI/TUI runs, orconsole: falseto keep API logs file-only (for example when a process manager already tails the log file).
Built-in log rotation. Codumentor can rotate the log file itself (a TimedRotatingFileHandler), independent of an external logrotate:
logging:
level: INFO
file: ./logs/codumentor.log
rotation_enabled: true
rotation_when: midnight # "midnight", "H" (hourly), "D" (daily), "W0".."W6", "S", "M"
rotation_interval: 1 # rotate every N of the above unit
rotation_backup_count: 30 # keep this many rotated files
rotation_utc: true # compute rollover times in UTC
| Field | Default | Description |
|---|---|---|
rotation_enabled | false | Enable timed rotation of the log file. |
rotation_when | "midnight" | Rollover unit (Python TimedRotatingFileHandler codes). |
rotation_interval | 1 | Number of rotation_when units between rollovers. |
rotation_backup_count | 30 | How many rotated files to retain; older ones are deleted. |
rotation_utc | true | Use UTC when deciding rollover boundaries. |
LLM payload tracing. Full request dumps -- every message and tool schema of every model call -- are written to a separate, non-propagating logger, codumentor.trace.payload, which is off by default. The main log gets one summary line per call instead (LLM request (stream): messages=… content_chars=… tools=… tools_digest=…). Enable tracing for a debugging session with trace_payload: true, a logger_levels entry codumentor.trace.payload: DEBUG, or the environment variable CODUMENTOR_TRACE_PAYLOAD=1. The trace goes to its own size-capped file (JSON when json_logging is on, so scripts/diff_llm_payloads.py can read it):
| Field | Default | Description |
|---|---|---|
trace_payload | false | Write full LLM request payloads to the trace file. |
trace_file | <file stem>.trace.log | Trace file path, next to the main log by default. |
trace_max_bytes | 52428800 (50 MB) | Size at which the trace file rotates. |
trace_backup_count | 2 | Rotated trace files to keep. |
JSON logging (for ELK/Splunk/Datadog — see also Deployment → JSON Logging):
| Field | Default | Description |
|---|---|---|
json_logging | false | Emit structured one-line-JSON logs. |
json_log_file | null | Separate file for JSON logs. If unset, JSON is written alongside the text log. |
json_include_context | true | Include the per-record context dict (session id, etc.). |
json_include_stack_info | false | Include stack info on records that carry it. |
json_include_process_info | false | Include process/thread identifiers. |
json_date_format | null | Override the timestamp format. |
The text file handler (and the JSON handler when enabled) is always configured when a log file path is set.