Config reference¶
~/.config/lazy-harness/config.toml is the single source of truth for a lazy-harness install. Every command — lh run, lh deploy, lh status, lh scheduler, the hooks — reads it on each invocation. There is no daemon, no in-memory cache: edit, save, run again.
The file location follows XDG: $LH_CONFIG_DIR/config.toml, then $XDG_CONFIG_HOME/lazy-harness/config.toml, then the platform default (~/.config/lazy-harness/config.toml on macOS/Linux, %APPDATA%\lazy-harness\config.toml on Windows).
Minimal example¶
The smallest config that loads cleanly:
[harness]
version = "1"
[agent]
type = "claude-code"
[profiles]
default = "personal"
[profiles.personal]
config_dir = "~/.claude-personal"
roots = ["~"]
[harness].version is the only field the parser hard-requires. Everything else has a default.
Fuller example¶
A more realistic shape with knowledge, monitoring, scheduler jobs, hooks, and the compound loop:
[harness]
version = "1"
[agent]
type = "claude-code"
[profiles]
default = "lazy"
[profiles.lazy]
config_dir = "~/.claude-lazy"
roots = ["~/repos/lazy"]
lazynorth_doc = "LazyNorth-lazy.md"
[profiles.flex]
config_dir = "~/.claude-flex"
roots = ["~/repos/flex"]
[knowledge]
root = "~/knowledge-store"
[knowledge.sessions]
enabled = true
[knowledge.learnings]
enabled = true
[knowledge.search]
engine = "qmd"
[monitoring]
enabled = true
db = "~/.local/share/lazy-harness/monitoring.db"
[monitoring.pricing.claude-sonnet-4-5]
input = 3.0
output = 15.0
[scheduler]
backend = "auto"
[scheduler.jobs.weekly-review]
schedule = "0 9 * * 1"
command = "lh knowledge sync"
[hooks.session_start]
scripts = ["context-inject"]
[hooks.session_stop]
scripts = ["compound-loop"]
[compound_loop]
enabled = true
model = "claude-haiku-4-5-20251001"
min_messages = 4
min_user_chars = 200
[lazynorth]
enabled = true
path = "~/vault/LazyNorth"
[context_inject]
enabled = true
max_body_chars = 3000
last_session_enabled = true
proposals_summary = true
repo_map_scope = "~/repos/work"
[harness]¶
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
version |
string | — | yes | Schema version. Currently "1". Parsing fails without it. |
[agent]¶
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
type |
string | "claude-code" |
no | Agent adapter to load. Determines the config-dir env var (e.g. CLAUDE_CONFIG_DIR) and binary name lh run execs. |
[profiles] and [profiles.<name>]¶
The [profiles] table holds one bare key (default) plus one sub-table per profile. The parser walks every key in [profiles], treats default as the default profile name, and treats every other key whose value is a table as a profile entry.
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
default |
string | "personal" |
no | Name of the profile used when none resolves. |
Each [profiles.<name>] sub-table:
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
config_dir |
string (path) | "" |
yes* | Agent config directory for this profile. ~ is expanded. |
roots |
list of strings | [] |
no | Filesystem roots that resolve to this profile (used by lh run and profile envrc). |
lazynorth_doc |
string | "" |
no | Per-profile LazyNorth doc filename. Overrides [lazynorth].universal_doc. |
qmd_collection |
string | "" |
no | QMD collection for automatic SessionStart suggestions from this profile. Empty omits suggestions. Names must match [A-Za-z0-9][A-Za-z0-9_-]*; invalid values are rejected with the profile and field named. No inheritance from the default profile or its identity. |
agent |
string | "" |
no | Agent adapter this profile runs. Empty inherits [agent].type. Registered values: claude-code, codex (the real adapter — hook trust reported by lh doctor, a native apply_patch edit path alongside its Bash heredoc path, see ADR-044; metered through its TranscriptReader per ADR-053), copilot (no edit guards and no context injection today — no tool has been observed editing a file, and additionalContext is unverified; see ADR-047), null. |
harness_binary |
string | "" |
no | Launcher this profile's generated hook commands name. Empty inherits lh. A bare name resolved from PATH, never a path. |
billing_model |
string | "per_token" |
no | How this profile's usage is billed: per_token or flat_rate. Persisted on every MetricEvent this profile's ingest produces (ADR-050). A misspelled value is rejected at load with a diagnostic naming it. |
root_default |
boolean | false |
no | Answers a bare lh run when two or more profiles share a root and their agents differ (multi-agent blast-radius design, decision 7). At most one profile per shared root may set this; the loader rejects a second, naming both. |
identity |
string | "" |
no | Agent-neutral persona this profile belongs to (personal, work) — the source directory under profiles/ and the destination of generated system docs are keyed by it, not by the profile name (ADR-068). Empty means "this profile is its own identity", today's behaviour exactly. When set, the profile's own name must be {prefix}-{identity}[-{suffix}] for its agent's prefix (claude, codex, copilot) — the loader rejects a mismatch, naming the profile key and the expected form. |
* config_dir has no parser-level requirement, but everything downstream (lh run, lh deploy, lh profile envrc) is meaningless without it.
agent and harness_binary are both "empty means inherit", and neither is read
directly: the deploy and run paths resolve them through one function each so they
cannot drift apart. Setting agent on one profile leaves every other profile on
the global [agent].type, which is what makes a profile — not an installation —
the blast radius when a new adapter is tried out.
A hook invoked with a named profile absent from a populated profile table is
refused; neither the default nor another profile running the same agent is used.
Blocking hooks emit the caller's native denial when its payload identifies the
agent, otherwise exit 2 with a diagnostic. Informational hooks emit no context
and report the unknown profile on stderr. Repair the deployed --profile value
before retrying. Empty profile tables retain the uninitialized-machine behavior.
Profiles sharing an identity¶
Two profiles of different agents can declare the same identity — claude-personal and codex-personal both identity = "personal" — and both then read and write profiles/personal/ instead of a directory per profile name. lh profile sync-system-doc writes each distinct (identity, agent) pair once: two profiles sharing both an identity and an agent (a second Codex subscription, codex-personal-alt) produce one write, not two. A directory under profiles/ that no configured profile resolves to is handled by whether the config has opted into identity: while no profile declares identity, such a directory keeps the pre-identity behaviour and is synced with the calling command's own agent; once at least one profile declares identity, it is reported orphaned and left untouched.
[knowledge] and sub-tables¶
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
root |
string (path) | "" |
no | Root of the knowledge store repository. Empty falls back to $LAZY_KNOWLEDGE_ROOT, then to the built-in default. |
The store's layout is not configured here. A knowledge.toml marker at the
store root declares its subdirectory names, and every consumer reads them from
there. See The knowledge store.
[knowledge.sessions]:
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
enabled |
bool | false |
no | Whether session export writes into the knowledge store. |
[knowledge.learnings]:
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
enabled |
bool | false |
no | Whether the compound loop persists distilled learnings. |
[knowledge.search]:
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
engine |
string | "qmd" |
no | Search backend. Only qmd is implemented today. |
[knowledge.embed] — how long lh knowledge embed gives QMD before giving up:
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
timeout |
int | 600 |
no | Seconds. A property of the host, not of the harness — with a GPU the whole corpus embeds in under a minute, while a CPU-only machine can spend far longer and fail at the default while genuinely draining its backlog. Raise it there rather than treating the failure as a bug. lh knowledge embed --timeout N overrides it for one run. |
[knowledge.structure] — code-structure layer (Graphify):
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
engine |
string | "graphify" |
no | Structure backend. Only graphify is implemented today. |
enabled |
bool | false |
no | Whether lh deploy wires the Graphify MCP entry into each profile's .claude.json. The graphify-mcp binary must also be present — the CLI alone is not enough. |
version |
string | "0.9.67" |
no | Pinned Graphify version. lh doctor flags drift between this and the installed binary so upgrades are explicit. See ADR-023. |
repos |
array | [] |
no | Absolute paths of the repositories whose code graph is kept fresh on schedule. Managed with lh knowledge graph add and inspected with lh knowledge graph list rather than edited by hand — add refuses a path that is not a git checkout. lh knowledge graph update rebuilds every registered repo's graph, which is what a scheduled job calls. |
[[knowledge.classify_rules]] (array of tables — see ADR-028):
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
pattern |
string | — | yes | Case-insensitive substring matched against the session cwd. |
profile |
string | — | yes | Value written to the export's profile: frontmatter when this rule matches. |
session_type |
string | — | yes | Value written to the export's session_type: frontmatter when this rule matches. |
Rules are evaluated in order; the first match wins. Omitting the section uses a built-in default list (matching lazymind/obsidian → vault, /repos/lazy/ → personal, /repos/flex/ → work). To opt out of all defaults, declare classify_rules = [] directly under [knowledge].
[[knowledge.classify_rules]]
pattern = "/srv/clients/acme/"
profile = "client"
session_type = "acme"
[[knowledge.classify_rules]]
pattern = "/opt/research/"
profile = "research"
session_type = "experiment"
[memory]¶
The [memory] block configures agent-side memory backends — tools the agent calls during a session. Today this is just Engram. It is independent of [knowledge] (file-based knowledge tree) and [monitoring] (SQLite metrics store).
[memory.engram] — raw episodic memory store (Engram):
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
enabled |
bool | false |
no | When true and Engram is on PATH, lh deploy adds its MCP entry through each agent's adapter. The independent engram-persist Stop hook does not check this flag. |
git_sync |
bool | true |
no | Stored preference for per-repo memory chunks. The harness does not start or disable Engram git sync from this field; configure sync in Engram separately. |
cloud |
bool | false |
no | Stored cloud-sync preference. The harness does not start or disable Engram cloud sync from this field; configure it in Engram separately. |
version |
string | "3.6.0" |
no | Configured comparison pin for lh doctor; defaults to the supported Engram version. Overrides are preserved and drift is reported, without gating persistence. See ADR-022 and ADR-029. |
binary |
string | "" |
no | Absolute executable path for engram-persist; empty resolves from PATH. Set it when the hook's environment needs an explicit path. MCP deployment and feature-version probes resolve engram independently from PATH. |
The interactive lh config memory --init wizard (see ADR-026) writes this block for you and merges it into an existing config preserving comments and unrelated sections.
Engram 3.0.0 requires expected_project on MCP mem_update and mem_delete calls. Clients must refresh their tool schemas after upgrading. The deterministic JSONL mirror continues to use CLI save; it does not build those mutation calls. Claude plugin 0.1.5 no longer registers MCP during SessionStart, so verify the registration separately when upgrading.
[monitoring]¶
The [monitoring] block controls the session-rollup SQLite store written by lh metrics ingest. It is independent of [metrics] (per-event sink fanout, see below) — they coexist.
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
enabled |
bool | false |
no | Whether the monitoring pipeline writes to SQLite. |
db |
string (path) | "" |
no | Path to the monitoring DB. Empty falls back to the data dir default. |
pricing |
table | {} |
no | Per-model pricing for cost rollups, e.g. [monitoring.pricing.<model>] with input / output / cache_read / cache_create (5-minute TTL write) / cache_create_1h (1-hour TTL write) USD-per-million-tokens floats. |
[metrics]¶
The [metrics] block configures per-event sink fanout: where each MetricEvent is written when the framework records one. Distinct from [monitoring], which controls session-level rollups.
The default — no [metrics] block at all — keeps everything local: only the built-in sqlite_local sink runs, and the framework does zero network I/O. Adding any other sink is opt-in.
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
sinks |
list of strings | ["sqlite_local"] |
no | Active sinks, in order. Built-ins: sqlite_local, http_remote. Any sink other than sqlite_local requires a corresponding [metrics.sink_options.<name>] block, otherwise the parser raises ConfigError. Unknown sinks are also a hard error. |
user_id |
string | "" |
no | Identity column stamped on every event. Empty falls back to lazy_harness.core.identity.resolve_identity() (env var → git config → hostname). |
tenant_id |
string | "local" |
no | Identity column stamped on every event. |
pending_ttl_days |
integer / null | null |
no | Outbox rows older than this are pruned. null keeps them forever (retried indefinitely under exponential backoff). |
[metrics.sink_options.<name>] — per-sink options table. Required for every non-sqlite_local sink named in sinks; ignored for unknown names.
http_remote options:
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
url |
string | — | one of | HTTP(S) endpoint receiving application/json payloads. Mutually exclusive with url_env. |
url_env |
string | — | one of | Name of an environment variable holding the endpoint, resolved at run time. Unset or empty deactivates the sink — not an error; the run continues on sqlite_local and lh doctor reports it as configured-but-inactive. Mutually exclusive with url. |
timeout_seconds |
float | 5.0 |
no | Per-request HTTP timeout. |
batch_size |
int | 50 |
no | Number of outbox rows claimed per drain pass. Lease (60 s) is held while the batch is in flight to avoid double-sending. |
[metrics]
sinks = ["sqlite_local", "http_remote"]
tenant_id = "acme"
pending_ttl_days = 30
[metrics.sink_options.http_remote]
url = "https://metrics.example.com/v1/ingest"
timeout_seconds = 5.0
batch_size = 50
Naming the variable instead of the endpoint keeps the value off disk and makes the remote sink opt-in per machine — a checkout that never sets the variable runs local-only with no config edit:
[metrics]
sinks = ["sqlite_local", "http_remote"]
[metrics.sink_options.http_remote]
url_env = "LH_METRICS_URL"
Setting both url and url_env on the same sink is a ConfigError: preferring
one silently would make a typo in the other look like it worked. Setting neither
is an error too, raised when the sinks are built.
A scheduler job (launchd/systemd) does not inherit an interactive shell's
exported environment, so url_env can resolve fine in a terminal and still
come back empty when the identical config runs on a timer. When the variable
is unset or empty, url_env resolution falls back to reading it from
<lh config dir>/secrets/metrics.env — KEY=value lines, # comments and
blank lines allowed, same format as per-profile secrets files. The file must
be owner-only (chmod 600); one that grants any group or other permission is
refused outright and treated the same as a missing file. The environment
always wins: when url_env is set in the process environment, the file is
never read.
End-to-end mechanics — outbox, drain, backoff, idempotency: how the metrics ingest pipeline works.
[scheduler] and [scheduler.jobs.<name>]¶
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
backend |
string | "auto" |
no | Scheduler backend. auto picks launchd (macOS), systemd (Linux with systemctl), or cron. All three install jobs. |
timezone |
string | unset | no | IANA zone the schedules are read in, e.g. "America/Argentina/Buenos_Aires". Unset means the machine's own zone. |
timezone exists because a headless host usually runs Etc/UTC while the schedules are written for a human's day. Without it, a job declared 0 8 * * 1 fires at 08:00 UTC — the wrong hour anywhere else, with every status view reporting it as correct.
Only the systemd backend can express it, as a suffix on OnCalendar=. launchd reads StartCalendarInterval in local time and has no key to override it, so rather than accept the field and ignore it, it refuses to install when the declared zone is not the one the machine currently observes. That keeps a single config shared across a Mac and a Linux host honest.
Each [scheduler.jobs.<name>] sub-table:
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
schedule |
string | — | yes | Cron expression, restricted to the subset below. Missing → ConfigError. |
command |
string | — | yes | Shell command to run on each fire. Missing → ConfigError. |
The job's name is the TOML key — there is no name field inside the table.
Supported schedule expressions¶
schedule is a five-field cron expression, but each backend only accepts what it can express exactly. An expression the backend cannot translate faithfully aborts lh scheduler install and installs nothing — no job is written, so the whole set stays as it was. The alternative, approximating, is what previously caused every non-daily job to be installed as hourly.
launchd accepts:
| Form | Example | Installed as |
|---|---|---|
| Every hour on a minute | 0 * * * * |
hourly at :00 |
| Daily at a time | 0 10 * * * |
daily 10:00 |
| Minute step dividing 60 | */15 * * * * |
:00, :15, :30, :45 |
| Hour step dividing 24 | 0 */6 * * * |
00:00, 06:00, 12:00, 18:00 |
| Weekly | 30 3 * * 0 |
Sundays 03:30 |
| Monthly | 15 2 1 * * |
the 1st, 02:15 |
launchd rejects, with the reason named in the error:
- Ranges and lists in any field (
1-5,1,15) — declare separate jobs. - A month restriction (
0 9 1 6 *). - A step that does not divide its field evenly (
*/45), because cron's uneven gaps have no uniform launchd equivalent. day_of_monthandday_of_weekboth restricted (0 9 1 * 1), because cron ORs them and launchd ANDs them.- Steps in more than one field at once.
- Values outside their cron range (
0 25 * * *).
[hooks.<event>]¶
The [hooks] table is keyed by the config.toml event name (session_start, pre_compact, session_stop, etc. — snake_case; see the event glossary for the full mapping to Claude Code's own SessionStart/Stop/... names). Each event sub-table takes:
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
scripts |
list of strings | [] |
no | Bare hook names to run for this event, in order (e.g. context-inject, not lh hook context-inject). Builtins resolve from the package registry; user scripts resolve from the configured hooks directory. An unresolved name is silently skipped. |
external |
list of strings / tables | [] |
no | Optional third-party commands to ensure are present in every profile whose agent the entry is declared for (all of them, by default). A bare string inherits the event's default matcher; a table pins its own ({ command = "...", matcher = "..." }) and may narrow it to named agents (agents = ["codex"]). The command may name {profile} and/or {config_dir} (ADR-054); any other {...} is rejected at deploy with a diagnostic naming it. |
Example:
[hooks.session_start]
scripts = ["context-inject"]
[hooks.session_stop]
scripts = ["compound-loop"]
external — hooks owned by another tool¶
A third-party tool may install its own native hook groups. Declaring an
equivalent command here only ensures that at least one matching group exists in
every profile. It does not transfer lifecycle ownership to the harness: removing
the declaration stops re-ensuring it, but does not authorize lh deploy to
delete an already installed group. Remove it through the tool or configuration
that owns it.
[hooks.user_prompt_submit]
external = ["/usr/local/bin/some-tool hook"]
[[hooks.pre_tool_use.external]]
command = "/usr/local/bin/some-tool hook"
matcher = "AskUserQuestion"
{profile} and {config_dir} are expanded for each profile. Escape literal
braces as {{ and }}: echo ${{HOME}} deploys as echo ${HOME}, and
awk "{{print $1}}" deploys as awk "{print $1}". Commands with no braces need
no changes. Invalid placeholders make lh deploy exit with a diagnostic naming
the command and field.
agents limits an entry to profiles whose resolved agent is listed
(claude-code, codex, copilot); empty or absent means every agent. An
unknown name fails load_config with a diagnostic naming it and the known
agents. lh deploy names each profile it skips with
· <command> omitted in '<profile>': declared for agents <list>. Narrowing an
entry stops ensuring it in the other profiles; like removing it, it does not
delete a group already installed there.
[[hooks.pre_tool_use.external]]
command = "graphify hook-guard search"
matcher = "Bash|Grep"
agents = ["codex"]
lh deploy preserves hook entries it cannot prove are its own, on every event —
including events the harness has no concept of — and reports each one. An
equivalent richer native group satisfies an external declaration without
losing native metadata, and pre-existing foreign duplicates remain distinct. In
Codex, managed groups are replaced in their existing positions so preserved
foreign groups keep their positional trust identity. Surplus managed groups are
appended only after every existing group, so an addition never shifts a foreign
group unnecessarily; removing an earlier managed slot can still shift it. A
missing external group is inserted right after the event's last harness
group instead, so a foreign tool that installed itself last stays last.
Malformed hooks.json content is refused rather than overwritten. In Claude Code, a foreign entry
whose matcher would invalidate the whole settings file is repaired and reported.
lh selftest checks the same schema, since a failure that stops hooks from
running cannot be detected by a hook.
Copilot stores managed builtins and external declarations in separate native hook artifacts. On the first deploy after that split, declarations from the former combined artifact that cannot be proven managed are moved intact to the external artifact, including metadata and duplicates. The managed replacement and external migration are planned together; invalid legacy JSON refuses the plan, and deploy snapshots cover both paths for rollback.
A package builtin under scripts is managed: later deploys may replace or
remove it. A user script resolved from the configured hooks directory has the
same ensure-present lifecycle as external: repeated deploys de-duplicate its
exact native declaration, while later omission does not authorize deletion.
Remove that declaration explicitly from the owning native configuration when it
should stop running.
Per-profile commands via {profile} / {config_dir}. A single external entry is emitted to every profile with the same command text — for a command that needs to know which profile it is running under, or that profile's own directory, command may name {profile} and/or {config_dir} and lh deploy expands them per profile before writing:
[hooks.session_start]
external = [
{ command = "bash {config_dir}/hooks/herdr-agent-state.sh session" },
]
{config_dir} expands to the profile's config_dir field exactly as declared — ~/.claude-lazy, not the resolved absolute path — so the command stays portable across machines with different home directories, the same reason the harness's own generated hook commands never embed one. A command with no {...} in it is unchanged. Naming any placeholder other than profile or config_dir is refused at deploy with a diagnostic naming it, rather than deploying a command that fails at hook-fire time.
[hooks.pre_tool_use] — security hook overrides¶
The pre_tool_use event accepts typed policy and memory-threshold options in
addition to scripts and external. Policy applies to every profile reading the
same harness config. Mechanics and static-analysis limits:
how hooks work — pre-tool-use-security.
An options-only table inherits the default hook scripts. An explicit
scripts = [] suppresses them; saving and loading preserves that distinction.
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
allow_patterns |
list of strings | [] |
no | Legacy data preserved for migration. The security hook no longer evaluates these regexes or grants exemptions from them. |
recursive_delete_roots |
list of strings | [] |
no | Absolute cleanup roots. Only one simple literal recursive rm can be exempted, and every operand must resolve strictly below a declared root. Filesystem-root aliases, traversal, escaping symlinks, operators, wrappers and expansions cannot grant an exemption. |
denied_commands |
list of strings | [] |
no | Opt-in literal executable basenames, matched in recognized command positions. Names must match [A-Za-z0-9_][A-Za-z0-9_.+-]*, not paths or regexes. No command is prohibited by default. |
claude_md_max_lines |
positive integer | inherited | no | Override the memory-size consumer's line ceiling. Omission retains the consumer default. Booleans and nonpositive values are rejected by the full config loader. |
claude_md_max_bytes |
positive integer | inherited | no | Override the memory-size consumer's byte ceiling, with the same validation and inheritance as the line ceiling. |
[hooks.pre_tool_use]
recursive_delete_roots = ["/absolute/project/.worktrees", "/absolute/scratch"]
denied_commands = ["example-cli"]
Rules and migration:
- Replace reviewed cleanup regexes with explicit roots; no regex is migrated automatically. Other legacy exceptions, including Terraform and SQL, have no automatic replacement. Incidental allowed text never rescues another operation.
- Roots themselves and paths outside them are never eligible for cleanup. Relative operands use the tool event's cwd. Quoted spaces, reordered flags and multiple allowed operands are supported; ambiguous syntax is refused.
- The full config loader and hook share
parse_security_policy. A missing file or missing section means empty lists. Unreadable/malformed config, invalid policy values or unknown keys make the hook deny command execution. - The policy is static inspection, not an OS execution boundary. Its limits and the required trusted-directory assumption are documented in the mechanics link above. Environment restrictions require explicit configuration and deployment.
[compound_loop]¶
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
enabled |
bool | false |
no | Whether the compound-loop hook fires. |
model |
string | "claude-haiku-4-5-20251001" |
no | Model used to distill learnings. |
min_messages |
int | 4 |
no | Floor on session length before the loop runs. |
min_user_chars |
int | 200 |
no | Floor on total user-message chars before the loop runs. |
debounce_seconds |
int | 60 |
no | Minimum gap between two loop firings on the same session. |
reprocess_min_growth_seconds |
int | 1800 |
no | Minimum seconds of JSONL growth since the last done/ task before a Stop event re-queues. Bypassed by the session-end producer and lh knowledge handoff-now. See ADR-019. |
timeout_seconds |
int | 120 |
no | Hard timeout on the loop's model call. |
grading_enabled |
bool | true |
no | When true, the worker persists the session grade to grades.jsonl; when false, the grade is dropped. Grades never reach a PRJ note. See ADR-021. |
lazymind_dir |
string / null | null |
no | Unused since ADR-072 removed the PRJ snapshot: still parsed and written back, never read. It is independent of [knowledge].root. |
slim_handoff_enabled |
bool | true |
no | When true and the loop's gates block evaluation, a deterministic fast-path still writes handoff.md — branch, last user prompt, files touched — with no model call. See ADR-030. |
max_pending_proposals |
int | 10 |
no | Pending claude-md.proposal.md rules above which the loop stops queueing new ones. While the queue is at the cap, proposals a session produces are held back, not queued: they are appended to proposals-held.jsonl next to the queue (one {ts, rule, rationale, project} record per line), which neither counts toward the cap nor reaches the grader as a rejection, and nothing moves them into the queue automatically. While the queue is at the cap the evaluator prompt also leaves out the pending queue and the claude_md_proposals key, so the grader is not asked for proposals that would only be held. Emission resumes once the queue is drained with lh memory proposals, and lh doctor lists every halted queue on the machine. A queue nobody drains crowds the session-start context budget and teaches the grader nothing. |
backend |
string | "claude" |
no | Deprecated — replaced by [llm.roles].distill. Still honoured when no [llm] table defines that role. The one-time deprecation warning fires only when this key or model is declared with a value other than its default: a config that leaves both alone never wrote the keys the notice asks it to migrate. See [llm]. |
backend_options |
table | {} |
no | Deprecated — replaced by the fields on [llm.backends.<name>]. Still honoured alongside backend. |
[hooks.pre_tool_use_git_scope] — git-scope hook overrides¶
The pre-tool-use-git-scope hook keeps its independent allow_patterns contract. The security hook no longer evaluates its legacy regexes, and scoped cleanup roots never exempt stash operations. Mechanics: how hooks work — pre-tool-use-git-scope.
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
allow_patterns |
list of strings | [] |
no | Python re.search regexes. A git stash the hook judges unsafe is allowed through when any pattern matches the whole command. |
[hooks.pre_tool_use_git_scope]
allow_patterns = [
# A scripted cleanup that manages its own stash entries by tag
"scripts/reset-worktrees\\.sh",
]
Choosing an LLM backend¶
Inference backends are now declared in the [llm] table and selected per role. The [compound_loop] fields above still work — they map to a role named distill — but new configuration belongs in [llm].
[llm]¶
Which model answers which kind of work. Introduced by ADR-039, superseding the single global backend of ADR-033.
A backend is a named provider with its options. A role is a name for a kind of work that points at one backend. Callers ask for a role, never for a provider, so which model serves a given job is a configuration decision rather than a code change.
[llm]
default_role = "distill"
[llm.backends.haiku]
type = "claude"
model = "claude-haiku-4-5-20251001"
[llm.backends.local]
type = "ollama"
model = "qwen2.5-coder:7b"
[llm.backends.openrouter]
type = "openai-compatible"
base_url = "https://openrouter.ai/api/v1"
model = "..."
api_key_env = "OPENROUTER_API_KEY"
[llm.roles]
distill = "haiku" # the compound loop's own distillation
classify = "local" # cheap, high-volume work
[llm]¶
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
default_role |
string | "" |
no | Role used when a caller names none. Falls back to distill. |
backends |
table | {} |
no | Named backends, under [llm.backends.<name>]. |
roles |
table | {} |
no | Maps a role name to a backend name, under [llm.roles]. |
[llm.backends.<name>]¶
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
type |
string | "claude" |
no | claude, ollama, mlx, or openai-compatible. |
model |
string | "" |
no | Provider model id. Empty falls back to the backend's own default. |
base_url |
string | "" |
no | Required for openai-compatible. ollama presets http://localhost:11434, mlx presets http://localhost:8080. |
api_key |
string | "" |
no | Literal key. Mutually exclusive with api_key_env. |
api_key_env |
string | "" |
no | Preferred. Names the environment variable holding the key; the value never appears in config.toml. |
api_key and api_key_env together is a hard configuration error. Prefer api_key_env: config.toml is often kept in a dotfiles repository, and a literal key committed there is a leak. The variable is read at call time. If it is unset or empty, resolution falls back to <lh config dir>/secrets/metrics.env, using the same KEY=value format and owner-only permission rule as url_env. The environment wins when it has a value. A scheduled job does not inherit an interactive shell's environment, so this file is how it gets the key: lh scheduler install writes only PATH into the plist or unit, which is not an owner-only file.
Roles in practice¶
Nothing routes to a local model until you say so. A role that names a backend which does not exist is a hard error, reported by lh doctor rather than discovered at run time.
There is no automatic fallback between backends. If the backend serving a role is down, the call fails and says so. Falling through to a billed model would turn a cost optimisation into a silent cost surprise, and would hide the fact that the local backend is broken.
lh doctor reports every role: which backend serves it, whether the endpoint answers, and — for a backend using api_key_env — whether the named variable resolves, never what it resolves to.
[lazynorth]¶
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
enabled |
bool | false |
no | Whether LazyNorth context injection is active. |
path |
string (path) | "" |
no | Root of the LazyNorth directory. |
universal_doc |
string | "LazyNorth.md" |
no | Default LazyNorth doc filename. Overridable per-profile. |
[context_inject]¶
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
enabled |
bool | true |
no | Whether the SessionStart hook injects context into the session. |
max_body_chars |
int | 3000 |
no | Cap on injected body length. |
last_session_enabled |
bool | true |
no | Whether to include a digest of the previous session matching the canonical project and exporting identity. |
qmd_suggest_enabled |
bool | true |
no | Whether the branch name is used to search the invoked profile's explicit qmd_collection. Missing scope omits suggestions. |
qmd_suggest_top_k |
int | 3 |
no | How many suggestions that lookup returns. |
graphify_surface_enabled |
bool | true |
no | Whether the code-structure summary for the current repo is injected. |
proposals_summary |
bool | true |
no | Whether a one-line summary of pending claude-md proposals is emitted even when the full proposals section is dropped to fit the char budget. |
repo_map_scope |
str | "" |
no | Directory tree whose sessions receive the repo-map doc. Empty disables the section; a session whose cwd is outside the tree never pays for it. |
repo_map_doc |
str | "docs/repos.md" |
no | Path to the map, relative to the agent runtime dir. Only read when repo_map_scope is set. |
repo_map_max_chars |
int | 1200 |
no | Cap on the injected map, cut on a line boundary with the remainder announced. Keeps a long map from starving every other section of the body budget. |
Automatic QMD suggestions use qmd search --json --collection <name> and accept
only hits whose qmd://<name>/ URI matches that collection. A missing collection
or failed search produces no suggestions. Configure an existing collection
explicitly per profile; the harness does not infer collection names. Interactive
searches keep their existing separately selected scope.
Claude Code and Codex session exports now record project_key, project_root,
source_profile and source_identity. Lookup matches the canonical
host/owner/repo key and the invoking profile's resolved identity. Profiles of
different agents that declare the same identity can share session context;
profiles without identity use their own name. Worktrees resolve to their main
repository. For a local/<name> key, lookup also
requires the exact main checkout path, preventing equal local basenames from
sharing sessions. project and the classified profile remain display metadata.
Legacy exports without canonical metadata or an explicit exporting identity are omitted from automatic injection; no basename or classified-label fallback is used. Exports without a recorded, existing absolute cwd cannot establish project scope. Existing unchanged exports are not upgraded automatically, and manual CLI exports without a source identity remain searchable without becoming automatic session context. The scoped metadata is recorded when a subsequent hook export writes the session. No history is deleted or reclassified.
Environment variable overrides¶
Three env vars relocate the directories lazy-harness reads and writes. They take precedence over XDG vars and platform defaults.
| Env var | Overrides | Default (macOS/Linux) |
|---|---|---|
LH_CONFIG_DIR |
Config directory | ~/.config/lazy-harness |
LH_DATA_DIR |
Data directory | ~/.local/share/lazy-harness |
LH_CACHE_DIR |
Cache directory | ~/.cache/lazy-harness |
XDG fallbacks (XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_CACHE_HOME) are honored when the explicit LH_* vars are unset.
Reloading config¶
There is nothing to reload — every lh invocation re-parses config.toml from disk, so saving the file is the entire reload step.