Skip to content

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_month and day_of_week both 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.