reyn.yaml¶
Project-level configuration. Checked in to git. Personal overrides go in reyn.local.yaml (gitignored, project root) or ~/.reyn/config.yaml (user-global).
Minimal example¶
llm:
model: standard
models:
light: gemini-flash-lite
standard: openai/gpt-4o
strong: anthropic/claude-3-5-sonnet-20241022
Top-level keys¶
How to read the "Written on / reload" column. Most keys can only be written
in reyn.yaml / reyn.local.yaml (= PRJ scope), which load_config reads
once at startup — editing them mid-run has no effect until a restart.
The exception is the runtime-mutable registries, which may also be written in
.reyn/config/<name>.yaml. Only what is written on that side is re-read at the
turn boundary (= hot reload); the same key written in reyn.yaml waits for a
restart like everything else. The file split is the write-gate boundary
(#2073), so the hot-reload loader structurally never opens reyn.yaml — to make
a setting hot-reloadable, put it under .reyn/config/ rather than adding to
reyn.yaml.
_HOT_RELOAD_FILES in src/reyn/config/loader.py is the source of which keys
are in that exception. The rows below are derived from it, but do not read a
count or a name list out of this prose — it does not follow the registry when
one is added.
⚠️ One exception to the exception: composers is read from the same 4-layer
combine as hooks (reyn.yaml ∪ .reyn/config/hooks.yaml ∪ per-agent ∪
per-session) but is not hot-reloaded — added or removed entries take effect
at the next session start.
Note that this table's axis is only the two project-level surfaces
(reyn.yaml and .reyn/config/). hooks, composers, and the permission keys
can additionally be written on an agent surface (.reyn/agents/<name>/,
.reyn/capability_profiles/<name>.yaml) and a session surface
(<session-state-dir>/config.yaml) — see
permission-model.
Placement principle (#4174 T7): a setting nests under an owning block when
only that subsystem's own code path ever reads it (e.g. embedding.cost_warn_threshold
— read once, inside the embedding/indexing pipeline, nowhere else). It gets a
top-level block of its own when it's read by code outside any single subsystem
— reachable from more than one place a session can be, at more than one moment
(e.g. cost_warn — read by the chat router at any /model switch and at
session startup, unrelated to which subsystem, if any, is running). A shared
substring in two keys' names is not evidence they're the same setting — check
who reads the value and from how many call sites, not what it's called. See the
cost_warn block for the specific pair that motivated writing
this down: cost_warn and embedding.cost_warn_threshold sound related and
aren't.
| Key | Type | Written on / reload | Description |
|---|---|---|---|
output_language |
string | PRJ only · restart | Default output language code (e.g. en, ja). Override with --output-language. |
safety |
map | PRJ only · restart | Runtime bounds and content-layer defenses: loop-detection caps, timeouts, on-limit policy, the untrusted-content threat scan + fence (safety.threat_scan, FP-0050), and operator bounds on the LLM spawn tree (safety.spawn, a DoS guard). See below. |
cost |
map | PRJ only · restart | Budget caps and rate limits (per-agent, daily, monthly). See below. |
web_fetch |
map | PRJ only · restart | The web_fetch tool + MCP registry calls: SSL settings. See below. |
gateway |
map | PRJ only · restart | The reyn web gateway's own settings: auth model, WebSocket inbound-frame ceiling, and which surfaces are mounted. Split from the old web: key, which conflated this with web_fetch above. See below. |
sandbox |
map | PRJ only · restart | Backend selection (backend), unsupported-platform policy (on_unsupported), the enforcement mode (mode: compat / strict / custom), and the agent-level sandbox policy (policy). See below. |
hooks |
list | both (.reyn/config/hooks.yaml side is hot-reloaded) |
Agent-lifecycle hooks. Four action schemes: template_push / exec / exec_capture / pipeline_launch. See below. |
embedding |
map | PRJ only · restart | RAG embedding: the master switch (enabled), model classes, batch sizing and concurrency, retry / backoff / timeout, tokenizer, and a cost-warning threshold. See below. |
chat |
map | PRJ only · restart | Chat-session runtime knobs: history compaction, reasoning/"thinking" text handling, the interactive renderer (render_mode), TUI gutters, body neutralization, and permitted image-URL schemes. See below. |
voice |
map | PRJ only · restart | ⚠️ Currently unavailable (no consumer). See below. |
audit_events |
map | PRJ only · restart | Rotation policy (size / age / cleanup period) for the P6 audit-event files under .reyn/events. Not WAL-events, not hook-events. See below. |
artifacts |
map | PRJ only · restart | The artifact-ref table fallback's own row cap (remote_fallback_limit, #4601) — used by a remote client's (and a post-restart local client's) Artifacts pane. See below. |
observability |
map | PRJ only · restart | Opt-in OpenTelemetry (OTLP) export of P6 audit-events. Off by default. See below. |
tool_use |
map | PRJ only · restart | Chat-layer tool-use scheme x transport selector (scheme, transport). See below. |
mcp |
map | both (.reyn/config/mcp.yaml side is hot-reloaded) |
MCP server definitions. See below. |
agent_id |
string | PRJ only · restart | Agent identity — stamped on the P6 audit trail and the outgoing HTTP header. Does not define or configure agents; agent definitions live in .reyn/agents/<name>/. See below. |
auth |
map | PRJ only · restart | OAuth provider configurations for reyn auth login. See below. |
cron |
map | both (.reyn/config/cron.yaml side is hot-reloaded) |
Scheduled skill executions. See below. |
external_transports |
map | PRJ only · restart | Inbound transport → MCP tool routing for chat (Slack / LINE / Discord etc.). See below. |
multimodal |
map | PRJ only · restart | Binary media (image/audio) size cap, on-oversize behaviour, artefact storage paths, and the base_url those artefacts are served under. See below. |
permissions |
map | PRJ only · restart | Default permission policy. See below. |
project_context_path |
string | PRJ only · restart | Markdown file injected into every phase system prompt. Unset (default): auto-resolves the cross-tool standard — AGENTS.md if present, else REYN.md (legacy fallback). Set an explicit path to pin one file; set "" to disable. See note below. |
llm |
map | PRJ only · restart | LLM-layer config: model selection (llm.model default class, llm.models class → LiteLLM string map, llm.model_class_by_purpose per-purpose override, llm.api_base proxy URL, llm.prompt_cache_enabled), plus routing (#1829) and retry (#1835). See below. |
delegation |
map | PRJ only · restart | Cross-agent delegation policy (#2081). |
cost_warn |
map | PRJ only · restart | High-cost-model gate (#1830 / FP-0052): warns before an expensive model is selected — and, despite the name, can block it (cost_warn.block_on_high_cost). See below. |
offload |
map | PRJ only · restart | Opt-in switch for the tool-result size gates. |
render_template |
map | PRJ only · restart | Operator-tunable output bounds for the render_template op (FP-0055 / #2679). |
fs_watch |
map | PRJ only · restart | Operator-declared filesystem watch paths (#2608 H4). |
composers |
list | both (but not hot-reloaded · restart) | Composer definitions. Empty (default) → start_composers is never called. |
skills |
map | both (.reyn/config/skills.yaml side is hot-reloaded) |
Skill declarations. Merged across config tiers by name (an explicit entry wins a collision). |
pipelines |
map | both (.reyn/config/pipelines.yaml side is hot-reloaded) |
Pipeline declarations. Same union-merge shape as skills. |
presentations |
map | both (.reyn/config/presentations.yaml side is hot-reloaded) |
Presentation-template declarations. Same union-merge shape as skills / pipelines. |
tui |
map | PRJ only · restart | Operator-tunable inline-TUI presentation thresholds — today just the status bar's context-usage-percent warn threshold. See below. |
Project context file (
project_context_path). Left unset, Reyn readsAGENTS.md— the cross-tool convention that Claude Code, Codex, opencode and others also read — so a project shared with those tools works as-is, with no Reyn-specific file. IfAGENTS.mdis absent, Reyn falls back toREYN.md(legacy). The first existing file wins, and a present-but-emptyAGENTS.mdis authoritative (it does not fall through toREYN.md).Migration. Existing
REYN.mdprojects keep working unchanged; new projects should preferAGENTS.md. To pin a specific file regardless of the standard, setproject_context_pathto that path; set it to""to inject no project context at all.
llm block¶
LLM-layer config: model selection (llm.model / llm.models /
llm.model_class_by_purpose — this section — plus llm.api_base /
llm.prompt_cache_enabled below), and llm.router / llm.retry (further
below: opt-in litellm.Router + Reyn self-retry backoff timing). #4174 T3:
model selection moved here from top-level model: / models: /
model_class_by_purpose: keys of the same name — same shapes, only the
nesting changed.
llm.models block¶
Each entry under llm.models: maps a class name to a LiteLLM model string or a dict that declares per-class LLM parameters.
Model classes vs model names — the resolution rule¶
Two kinds of position appear in config, and they follow opposite rules. The same rule applies to the completion models: block and the embedding.classes: block.
- Class position (a reference to a class):
model, per-agent / per-phase / per-op model overrides,embedding.default_class. These are closed-world — the value must name a class that exists inmodels:/embedding.classes:(or a built-in tier:light/standard/strong). A value that is not a known class is not silently treated as a literal model: - operator config (
model:in reyn.yaml) keeps a backward-compatible literal passthrough (you may putopenai/gpt-4odirectly); - a skill/op-supplied model (
op.model) that is not a known class is rejected and falls back to the runtime model (one warning), so a skill- or LLM-authored string cannot bypass the proxy config — the proxy config is the single source of truth for model selection. - Name position (the definition of a model): the
model:value inside amodels:/embedding.classes:entry. A name should beprovider/model(e.g.openai/gpt-4o,openai/nomic-embed-textfor a local model served behind a litellm proxy). A bare name with no/is accepted (some LiteLLM strings are bare) but warns at load — add the prefix if resolution misroutes.
In one line: a _class / tier position takes a class name (closed-world); a model position takes provider/model (validated). No position accepts both.
str form — literal (backward compatible)¶
If a str value contains /, it is treated as a literal LiteLLM model string:
llm:
models:
light: gemini-flash-lite
standard: openai/gpt-4o
strong: anthropic/claude-3-5-sonnet-20241022
All existing reyn.yaml files using str form continue to work without change.
str form — class reference shorthand (new)¶
If a str value has no /, it is a shorthand for {extends: <name>}. The name
is resolved against the flat namespace (user entries + built-in catalog):
llm:
models:
standard: claude-sonnet-thinking # equivalent to: standard: {extends: claude-sonnet-thinking}
An unknown shorthand (name not in user entries or built-ins) is a startup error.
dict form — plain kwargs¶
llm:
models:
standard: gemini-flash-lite # str form still OK alongside dict entries
strong:
model: anthropic/claude-3-7-sonnet # required
temperature: 0.0
max_completion_tokens: 16000 # preferred over max_tokens — see note
extra_body:
thinking:
type: enabled
budget_tokens: 8000
| Field | Required | Description |
|---|---|---|
model |
yes | LiteLLM model string. |
temperature |
no | Sampling temperature passed to litellm. |
max_completion_tokens |
no | Preferred max output tokens (enforced by OpenAI o1+ and most providers). |
max_tokens |
no | Legacy soft hint — ignored by many providers. Prefer max_completion_tokens. |
top_p |
no | Top-p sampling passed to litellm. |
extra_body |
no | Provider-specific payload (e.g. thinking for reasoning models). |
reasoning_effort |
no | Reasoning budget for the model: minimal / low / medium / high / disable / none. Validated at load (see below). |
extends |
no | Inherit from a named class and deep-merge overrides (see below). |
api_base |
no | Per-class endpoint override — a routing field, not forwarded as a litellm kwarg. |
provider |
no | Per-class litellm custom_llm_provider — a routing field, not forwarded as a litellm kwarg. |
stream |
no | Whether calls for this class stream. A reyn field, not forwarded to litellm; overrides the capability query in both directions. Omit to let reyn decide — see below. |
stream_options |
no | Not settable. No reyn meaning, and it breaks the collect-whole branch — rejected at load. |
max_input_tokens |
no | Operator-declared context-window CEILING for this class (#4689). A reyn field, not forwarded to litellm — takes priority over the LiteLLM catalog UNCONDITIONALLY (not just when the catalog lookup fails). Omit to let reyn read the catalog, falling back to a conservative 128,000-token default if the model isn't cataloged. |
| (any other field) | no | Silently passed through to litellm (passthrough policy). |
Cost limit: use
max_completion_tokens, notmax_tokens.max_tokensis a legacy soft hint that many providers ignore; it has no enforcement power on OpenAI o1+ or Anthropic models.max_completion_tokensis enforced at the API level.
Field policy: model is the only required field. Most other fields are passed directly to litellm.acompletion without validation — unknown fields are silently forwarded (future-proof); typos cause silent litellm failures, not reyn errors. Four exceptions are handled at load instead: reasoning_effort (below), stream (consumed by reyn, type-checked), stream_options (rejected outright), and max_input_tokens (consumed by reyn, type-checked — never forwarded to litellm, closing the same "accepted but silently does nothing" gap the other three exceptions close).
stream — whether this class's calls stream¶
Reyn decides this per call, from a litellm capability query inside the single
completion funnel (recorded_acompletion). stream: is the operator's answer,
and it wins over the query in both directions:
llm:
models:
my-new-model:
model: some-model-too-new-for-litellm
stream: true # stream, whatever the catalog says
picky-endpoint:
model: openai/gpt-5
stream: false # never stream, even though the catalog allows it
Omit the field to leave the decision to reyn. That is the right default; set it when you know something the catalog does not.
You often will. Reyn pins litellm's model table to the snapshot bundled with the
installed package (LITELLM_LOCAL_MODEL_COST_MAP, set in reyn/__init__.py to
silence a startup network fetch), so a model newer than that snapshot is absent
from it — permanently, not intermittently. An absent row is not a statement that
the model cannot stream, and reyn no longer reads it as one; but where the
catalog is actively wrong, this field is how you say so.
This is a reyn field: it is consumed by the streaming decision and never
forwarded to litellm.acompletion. That distinction is load-bearing — as an
ordinary passthrough kwarg it reached the collect-whole branch and made litellm
return a stream object that reyn read as a finished reply, surfacing as an error
naming neither stream nor the config:
EmptyLLMResponseError: LLM returned a 200 response with empty choices
(model=...); provider response: <litellm...CustomStreamWrapper object...>
A non-boolean value is rejected at load. Unlike the forwarded fields, a typo here would otherwise never reach anything that could complain about it.
stream_options (not settable)¶
Rejected at config load (ValueError, fail-fast). It has no reyn meaning, and
riding the kwargs passthrough it produces the same broken shape described
above.
max_input_tokens (per-class context-window ceiling, #4689)¶
Declare the context window reyn should budget against for a class — overriding the LiteLLM catalog unconditionally, not just when the catalog lookup fails:
llm:
models:
gpt-5-6-luna:
model: openai/gpt-5.6-luna
max_input_tokens: 128000 # operator's own answer, wins over the catalog
Every consumer of reyn.llm.model_budget.get_max_input_tokens (compaction, the
turn-budget force-close threshold, the status-bar context chip, ...) only ever
holds an already-resolved LiteLLM model STRING, never a class name — none of
those call sites change. Instead, at each reyn.llm.model_resolver.ModelResolver
construction (session/CLI/web startup), every class's declared
max_input_tokens is resolved to its model string and registered into a
process-shared table get_max_input_tokens consults FIRST, ahead of the
catalog.
Two classes resolving to the SAME model string must agree. If light and
standard both point at openai/gpt-4o but declare different
max_input_tokens values, registration raises
reyn.llm.model_budget.MaxInputTokensConflictError — which value should win is
ambiguous, so reyn refuses to guess (give them the same value, or point them at
different model strings). The SAME check applies across sessions sharing one
process with different configs — a later, conflicting declaration for a model
string another session already registered raises rather than silently
overwriting the first.
A non-positive or non-integer value is rejected at load, the same discipline
stream above uses — a config typo here would otherwise silently ride
spec.kwargs into litellm.acompletion as an unrecognized kwarg (accepted,
does nothing).
Complementary, not exclusive: some LiteLLM proxies also advertise a model's
window via GET /model/info (verified live for some providers, not yet for
others) — max_input_tokens here is the operator's own, explicit override,
useful specifically when that proxy-side declaration is absent, wrong, or the
model predates reyn's bundled catalog snapshot.
reasoning_effort (per-model reasoning budget)¶
Set how much the model is allowed to "think" before answering. Declared per model definition so it's explicit and easy to understand:
llm:
models:
light:
model: gemini/gemini-2.5-flash-lite
reasoning_effort: low # minimal | low | medium | high | disable | none
- Valid values:
minimal,low,medium,high,disable,none. An invalid value fails fast at config load (a clearValueErrornaming the bad value), not mid-call inside litellm. - Native mapping: the value is passed through natively to litellm, which maps it to
the provider's own reasoning budget. For Gemini (e.g.
gemini-2.5-flash-lite):low→ thinking budget 1024,medium→ 2048,high→ 4096,minimal→ model-specific (512 for flash-lite),disable/none→ 0. No hand-rolledextra_bodyneeded. - Mutually exclusive with an
extra_bodythinking config:reasoning_effortis the thinking-budget control, so declaring bothreasoning_effortand anextra_bodythinking config on the same model is rejected at load (pick one). - OpenAI summary opt-in (dict form): OpenAI reasoning models (o-series / GPT-5)
do not return raw reasoning text — they encrypt the chain and expose only an
optional summary, which is opt-in. For those models pass the dict form to
request the summary text:
litellm's GPT-5 transformation reads
llm: models: strong: model: openai/gpt-5 reasoning_effort: effort: medium # the budget level (validated, same set as above) summary: detailed # opt into summary text → rides into reasoning_content{effort, summary}. Provider difference: Gemini exposes raw reasoning text natively from the string form; OpenAI needs the dict +summaryfor any text (and even then it is a summary, not the raw chain). Withoutsummary, an OpenAI model'sreasoning_effortstill controls the budget but no reasoning text is displayed.
Reasoning text IS captured, displayed, and replayed. A non-zero
reasoning_effortsets the provider'sincludeThoughts=true; reyn captures the reasoning text, displays it (TUI + web, collapsible —chat.reasoning.display), and replays recent turns' reasoning into the next prompt (chat.reasoning.continuity). See thechatblock for the toggles. (For OpenAI models the displayed text is the summary and only when the dictsummaryopt-in is set — see above.)Known behavior — re-enables thinking on the tool-use path. Reyn does not force thinking off; it relies on the provider default (off for Gemini 2.5). Setting
reasoning_effortturns thinking on, including on the multi-turn tool-use path where Gemini previously had a parallel-tools + thinking interaction. Verify behavior on your model if you enable it for a tool-heavy agent.Proxy passthrough (openai-compat). When routing through a litellm proxy, reyn whitelists
reasoning_effortviaallowed_openai_paramsso it is forwarded to the proxy (which maps it to the provider's native thinking budget) instead of being rejected as an unsupported OpenAI param. No extra configuration needed.
Skill / phase override: NOT supported. Operator config (reyn.yaml) is the single source of truth for LLM parameters. Skill authors specify class names only (e.g. model_class: strong).
Merge order: Reyn-managed settings (timeout, num_retries, proxy routing) always take precedence over operator-declared kwargs so proxy configuration is never bypassed.
dict form — extends field (new)¶
Use extends to inherit from another class and override specific fields. The referenced
name is resolved against the same flat namespace (user entries + built-in catalog).
llm:
models:
# Inherit claude-sonnet-thinking built-in, reduce budget_tokens from 8000 → 4000.
# extra_body.thinking.type: enabled is carried from the base (deep merge).
reasoning-light:
extends: claude-sonnet-thinking
extra_body:
thinking:
budget_tokens: 4000
# Multi-level: reasoning-heavy extends the user-defined reasoning-light above.
reasoning-heavy:
extends: reasoning-light
extra_body:
thinking:
budget_tokens: 16000
max_completion_tokens: 32000
Deep merge: nested dicts are merged recursively. Only the keys you specify under
extra_body.thinking are overridden; sibling keys (e.g. type: enabled) are carried
from the base. Scalars and lists are replaced, not merged.
Multi-level chains: any depth is allowed. Reyn resolves the full chain at startup.
Cycle detection: circular extends references (e.g. A extends B, B extends A) are
detected at startup and raise a configuration error.
Unknown references: referencing a name not in the namespace is a startup error —
including light / standard / strong themselves if your reyn.yaml doesn't map
them (see below).
No built-in model catalog¶
Reyn ships no built-in catalog of concrete provider/model targets — light,
standard, and strong are reyn's own vocabulary (the 3 standard tiers, in
ascending cost order), but what each one actually points to is entirely up to your
llm.models: mapping above. reyn init writes a starting mapping into the
reyn.yaml it generates; edit it to match your provider. A class with no mapping
in effect (no reyn.yaml, no reyn.local.yaml, or a models: block that omits
it) is a startup error naming the missing class — reyn does not silently fall back
to a hidden default for any class, tier or custom.
llm.model_class_by_purpose — per-purpose model class¶
Reyn makes several internal LLM calls beyond the main agent reply, each tied to a
logical purpose. By default every purpose uses your configured model (the
default class) — routing follows the model you configured; there is no hidden
cheaper tier. model_class_by_purpose lets you override the class for a
specific purpose; an unset purpose falls back to model.
| Purpose | What it covers |
|---|---|
router |
The per-turn chat router / intent classification. |
tool |
The default class for tool-spawned skill runs. |
judge |
Output-judging / evaluation calls. |
llm:
model: standard # the default class for every purpose
models:
standard: openai/gpt-5.4
light: openai/gpt-4o-mini
model_class_by_purpose:
router: light # opt INTO a cheaper per-turn router (an explicit choice)
# tool / judge unset → follow `model` (gpt-5.4)
Cost note: the router runs on every turn, so the cheap-router optimisation is
still available — it is now an explicit one-line opt-in (router: light) rather
than a hidden default. Explicit per-call selections (a skill's op.model) still
win over this fallback. Unknown purpose keys are warned (not fatal) at load time —
except compaction (#3785), which fails to load: compaction used to be
configurable here but never tracked a /model switch mid-session, so it always
follows the conversation's active model now, and a config that still sets this
key is refused with a remedy rather than silently ignored.
llm.model_max_class — model-class ceiling (#4206 T1)¶
model_max_class declares an operator ceiling on the model class any call may
use (light / standard / strong, the same cost order every tier list
uses). It is restrict-only, reject-not-clamp — the same shape as
sandbox.max_timeout_seconds's LLM-extensible ceiling: a call whose resolved
class exceeds the ceiling is REJECTED, naming the actual ceiling, before
litellm.acompletion is ever invoked — never silently downgraded to a
cheaper class.
Unset (default) means unbounded — byte-identical to every deployment before
this field existed. Enforcement happens once, inside recorded_acompletion
(the single #1190 cost-observability chokepoint every LLM call passes
through) — a call site that resolved a model CLASS cannot forget to apply
it, not "a new call site cannot forget to apply it" — the ceiling
compares against a class, and #4324 records two structural (not
implementation) exceptions where no class ever reaches this chokepoint:
- A raw model string —
resolve_purpose_classreturns it unchanged; there is no class to compare against, so the ceiling is a no-op for that call. Not a bug: a raw string is, by construction, outside thelight/standard/strongvocabulary this field's ceiling is defined over. - A
model_class=Nonecall —dev/dogfood/interpretation.pyanddev/dogfood/verifiers/reply.pyboth call out with no class at all (real-cost calls, not free/auxiliary ones), so they never enter the comparison this field performs.
model_max_class is a declared boundary — complete only within its own
vocabulary (a call that resolves to a class). It is not the tool for
covering every call regardless of vocabulary; a measured boundary
(cost.* below) is. Owner ruling, 2026-08-15: cost caps stay opt-in —
reyn does not turn one on by default, matching the standing "security/cost
gates are opt-in, UX and predictability come first" policy. Concretely:
in an environment with no cost.* hard limit configured, a raw
model-string call has no ceiling at all — neither this field's class
comparison (structurally can't apply) nor a cost cap (unset by default)
bounds it. An operator who wants that call bounded sets a cost.* hard
limit explicitly (see the cost block below); nothing bounds it for them
otherwise.
llm block¶
LLM-layer config: llm.router (opt-in litellm.Router) and
llm.retry (backoff timing for the Reyn self-retry layer).
llm:
router:
use: false # master switch (env REYN_LLM_USE_ROUTER is the fallback)
num_retries: 3 # infra-exception retries (litellm Retry-After aware)
fallbacks: # primary model → ordered list of fallback models
openai/gpt-4o-mini:
- openai/gpt-3.5-turbo
cooldown_time: 60 # seconds a deployment is cooled down after failures
allowed_fails: 2 # failures before a deployment is cooled down
retry_policy: # per-exception-type retry counts (litellm.RetryPolicy)
RateLimitErrorRetries: 5
TimeoutErrorRetries: 3
retry:
jitter: true # equal jitter (AWS pattern): sleep = base/2 + uniform(0, base/2)
respect_retry_after: true # honour provider Retry-After header (capped at max_backoff)
llm.router fields¶
| Field | Type | Default | Meaning |
|---|---|---|---|
use |
bool | false |
Master switch. false → direct litellm.acompletion. Supersedes REYN_LLM_USE_ROUTER. |
num_retries |
int | 3 |
Infra-exception retry count (Retry-After aware). Supersedes REYN_LLM_ROUTER_NUM_RETRIES. |
fallbacks |
map | {} |
primary_model → [fallback_model, …]. Empty → single-deployment Router (no chain). |
cooldown_time |
float|null | null |
Seconds a deployment is cooled down after allowed_fails failures. Only meaningful with a fallback chain. |
allowed_fails |
int|null | null |
Failures before a deployment is cooled down. |
retry_policy |
map|null | null |
Per-exception-type retry counts. Absent (null) → litellm defaults (num_retries applies uniformly). When set, constructs a litellm.RetryPolicy and passes it to the Router. Supported keys: RateLimitErrorRetries, TimeoutErrorRetries, BadRequestErrorRetries, AuthenticationErrorRetries, ContentPolicyViolationErrorRetries, InternalServerErrorRetries. |
On the Router path, retry count is config-only: num_retries is taken from
llm.router.num_retries (a per-call max_retries is not applied), so the retry
budget has a single source. (On the direct, non-Router path the per-call
max_retries is unchanged.)
What degrades if litellm's Router misbehaves (#4354 follow-up — owner's
"delegate what litellm already does" ruling means reyn increasingly relies on
litellm's own retry/fallback/cooldown machinery working correctly, not a
reason by itself to stop delegating): num_retries / fallbacks /
cooldown_time / allowed_fails / retry_policy above are all config reyn
merely HANDS to litellm.Router — reyn keeps no parallel bookkeeping of
deployment health, cooldown state, or which fallback fired. If the Router's
own cooldown/fallback logic misbehaves (wrong deployment skipped, a cooldown
that never clears, a fallback chain that doesn't fire), reyn has no
independent view to detect or override it — debugging that is inspecting
litellm's own Router state/logs, not reyn's, since (post-#4347/#4354) reyn no
longer holds a second copy of per-deployment credential/rotation state to
cross-check against. This is the same shape #4398's chars//4 token-count
fallback names for token_counter's own failure mode: delegating is the
right call, but the doc should say what the delegation costs when the
delegated-to system is wrong, not just that delegating is correct.
llm.retry fields¶
Controls the timing of the Reyn self-retry layer only (semantic-retry
behaviours — EmptyLLMResponseError, empty_stop_retry, compaction shrink — are
unaffected). Both defaults are true.
| Field | Type | Default | Meaning |
|---|---|---|---|
jitter |
bool | true |
Apply equal jitter (AWS pattern): sleep = base/2 + uniform(0, base/2) where base = min(base_s * 2**attempt, max_backoff). Range [base/2, base]. Prevents thundering herd when parallel chains retry in lockstep. false → pure exponential (2 s, 4 s, 8 s, 16 s). |
respect_retry_after |
bool | true |
When a retryable exception carries a Retry-After header (delta-seconds or HTTP-date), honour it (capped at _LLM_RETRY_MAX_BACKOFF_S = 16 s) instead of the jittered backoff. Falls back to jittered backoff when the header is absent or unparseable. false → always use jittered backoff. |
Router path: when
llm.router.use: true, the litellm.Router owns infra-exception retry with its ownRetry-Afterrespect. Thellm.retryfields only apply to the Reyn self-retry layer (= the direct, non-Router path, plusEmptyLLMResponseErroron both paths). See thellm.routerblock above.
chat block¶
Chat-session runtime knobs. chat.compaction controls chat-history compaction
(ratio-based budget; see reyn.local.yaml.example). chat.reasoning controls
model reasoning/"thinking" text handling. chat.render_mode selects the
interactive chat renderer/driver. chat.gutters sets the TUI conversation
pane's two gutter columns' start state.
chat:
render_mode: alt-screen # interactive chat renderer: alt-screen (default) | plain
reasoning:
continuity: true # persist reasoning to history + replay recent turns
display: true # show reasoning in the UI (TUI + web, collapsible)
recent_turns: 3 # turns of reasoning to replay; <=0 = unbounded
gutters:
left: true # TUI left gutter (state marker, 2 cols) shown at start
right: true # TUI right gutter (elapsed/tokens, 12 cols) shown at start
neutralize_body: false # opt-in ESC/OSC strip on agent-reply/tool-result body text
image_url_schemes: [] # opt-in scheme allowlist for present's image src fetch
empty_stop_retry: false # opt-in resend on an empty router response
chat.render_mode¶
Selects which renderer/driver reyn chat uses on a TTY. A non-TTY
session (piped, CI, or a host with no real terminal) always falls back to
plain regardless of this value — the interactive Textual drivers need a real
terminal.
| Value | Behaviour |
|---|---|
alt-screen |
Default. Full-screen Textual (alt-screen driver). Terminal scrollback is auto-saved on enter and restored on exit; the previous conversation is rebuilt from history.jsonl on restart. This is the recommended mode. |
plain |
Force the plain line-based renderer (ConsoleChatRenderer, no Textual), genuinely equivalent to --cui — same renderer and same input-loop driver, not just the input driver. |
An unrecognised value — including a stale inline or auto from before this
table was narrowed to two values (owner instruction, inline's legacy
bounded driver was removed; auto was behaviourally identical to
alt-screen and carried no distinct behaviour) — warns and falls back to
alt-screen.
chat.gutters fields¶
The TTY conversation pane draws two fixed-width gutter columns — a left state marker (2 columns) and a right elapsed/token readout (12 columns) — and each costs its width on every row. These two flags set what the pane opens with; they are independent, matching the underlying widget's own granularity.
| Field | Type | Default | Description |
|---|---|---|---|
left |
bool | true |
Show the left (state-marker) gutter when the pane opens. |
right |
bool | true |
Show the right (elapsed / turn-token) gutter when the pane opens. |
Either can also be toggled at any time from the keyboard — ctrl+g (left) and
ctrl+t (right), both listed in the TUI's Help pane. A keyboard toggle is
session-scoped: it changes the running pane only and never writes back here,
so these keys are the setting to change for a lasting preference. Hiding a
gutter hands its whole column back to the conversation body (an 80-column
terminal's body goes 66 → 78 columns with the right gutter hidden, → 80 with
both).
chat.neutralize_body¶
Opt-in ESC/OSC-control-sequence strip on the agent-reply / tool-result body
text (#3318). Off by default — reyn's stated policy is "UX/predictability over
security, security is opt-in". LLM-derived choice labels and intervention
prompts are always neutralized regardless of this flag (#3302, a separate,
unconditional display-boundary fix); this flag widens the same terminal
neutralizer (core/present/guard.get_neutralizer("terminal"), ESC/control
strip) to the conversation body content itself, where a raw ESC/OSC sequence
from tool output or an untrusted model reply could otherwise reach the
terminal on both the TUI conversation pane and the plain (--cui) renderer.
Three more cases are always neutralized regardless of this flag, same shape
as #3302's, all structurally unable to read it (a different code path
reaches the same terminal neutralizer, not _body_renderable — the ONE
function this flag actually gates):
- A tool's own one-line summary (
summarize_tool_result— the collapsed line shown above/instead of the full body, e.g. a failedexec'sexit 1: <stderr detail>) — unconditional since #4760. World-derived text (a sandboxed process's arbitrary stderr bytes) reaches that summary through every caller ofsummarize_tool_result. - The inline TUI's expanded tool-detail view (Space-toggled, #4697 —
_result_detail_lines/_dict_detail_linesintextual_chat/presenter.py) — unconditional since #4757, at its own seam: it takes noneutralize_bodyparameter at all, and never did. (Before #4757 this path was not neutralized —json.dumpsmerely escaped control bytes as a side effect, and the bare-string branch did not even do that.) - A failed tool call's own error line (
tool_call_failed'serr/error_message, shown on both the plain--cuirenderer'sformat_inline_messageand the TUI's_tool_result_line/_body_and_background) — unconditional since #4770. Traces todispatcher.py's own catch-all (f"{type(e).__name__}: {e}"wrapping any tool-handler exception — an MCP call, a sandboxed subprocess, a provider HTTP error), the same class #4760 fixed forsummarize_tool_result's stderr branch but explicitly did not cover — this is a structurally separate code path that never goes throughsummarize_tool_result's own return boundary.
So neutralize_body widens the SAME terminal neutralizer to exactly one
more surface — the full, collapsed body text _body_renderable renders —
not to "tool-result rendering" as a whole; the summary line, the expanded
detail view, and a failed call's own error line are unconditionally
covered either way.
| Field | Type | Default | Description |
|---|---|---|---|
neutralize_body |
bool | false |
Strip ESC/control sequences from agent-reply and tool-result body text before rendering. |
chat.image_url_schemes¶
Opt-in narrowing of which URL schemes present's image component will
fetch (#3846, owner ruling C). Default [] — unrestricted, both http and
https are fetched — the owner's stated rationale: "even without the bytes,
the record of what was presented is enough" (the value is the audit record
that an image was presented, not reyn proxying/verifying the bytes). Set to a
non-empty list to restrict to exactly those schemes, e.g. ["https"] to
reject plain http. Any scheme outside {http, https} is always rejected
regardless of this setting — nothing else is fetchable through an httpx
client. src is written by the model (present's blueprint is LLM-authored),
so the fetch always routes through the SSRF-pinned client
(_network.py's build_async_http_client(pin_ssrf=True)) unconditionally,
independent of this scheme setting.
| Field | Type | Default | Description |
|---|---|---|---|
image_url_schemes |
list[str] | [] |
Restrict present's image-src fetch to these schemes; empty = unrestricted (http + https). |
chat.empty_stop_retry¶
Opt-in retry of an empty router response (#4677). When the LLM returns
finish_reason="stop" with no content and no tool calls — a provider-level
glitch, observed at ~50% rate with weak models (B7-G12 measurement) — reyn
can resend ONE "resume" continuation prompt before surfacing the failure.
Owner default is now false (2026-08-14) — the retry was previously
hardcoded on in production with no config knob at all, until an incident
where 30 empty-response detections in one reyn-self run each cost a
second LLM call (30 turns → 63 llm_called). A measured benefit exists for
some environments (Trace-patch-replay: 0/10 → 10/10 narration recovery on a
specific empty-stop case with the retry on) — reports of this shape have
come from weaker/local-model setups (Qwen, LM Studio, Ollama, vLLM,
LiteLLM-fronted proxies); an operator on one of those sets this back to
true. This does not fix empty responses themselves — their root cause
is unmeasured (#3698's anyio cancel-scope is a candidate) — it only changes
what happens after one is detected. The router_empty_response_detected
audit event fires regardless of this setting.
| Field | Type | Default | Description |
|---|---|---|---|
empty_stop_retry |
bool | false |
Resend one "resume" prompt when the router loop detects an empty finish_reason="stop" response. |
chat.reasoning fields¶
Capture of the provider reasoning_content is always-on; these knobs gate
what happens afterwards. Both continuity and display default on.
| Field | Type | Default | Description |
|---|---|---|---|
continuity |
bool | true |
Persist reasoning to history and replay the recent turns' reasoning into the next turn's system prompt (cross-user-turn reasoning continuity, a text-section mirroring act_turn_reasoning). Opt-out to disable persist + replay. |
display |
bool | true |
Surface reasoning in the UI (TUI + web, collapsible). Opt-out to hide it. Independent of continuity. |
recent_turns |
int | 3 |
How many recent turns' reasoning to replay under continuity. <= 0 (e.g. 0 / -1) = unbounded (keep all). Bounding matters on Gemini — there is no provider auto-filter, so reasoning accumulates and is billed in full. |
Provider note: on the Gemini-via-proxy path the reasoning is replayed as a text section (the model sees it in-prompt), and
reasoning_contentis stripped from the wire-shape assistant messages to avoid a double-inject (litellm's vertex transformation would otherwise emit it natively too). Anthropic/DeepSeek direct-API require the nativereasoning_contentround-trip on the tool-use path; litellm auto-manages that when it's left on the wire — a known provider-dependency, not implemented here (proxy + Gemini reality).
safety block¶
Unified stop-condition namespace. Each value can be overridden per-invocation by the matching CLI flag. (The old top-level limits: key is gone; safety: is the single source of truth.)
safety:
loop:
max_router_calls_per_turn: 3 # chat-router calls per user turn
max_router_iterations: 5 # LLM tool-call iterations per user turn (CLI --max-iterations overrides)
max_tool_calls_per_turn: 50 # max tool_calls honoured from ONE completion (cost-bound); 0 = unlimited
max_hook_driven_turns: 25 # loop valve: cap hook self-continuation; resets on user turn; 0 = unlimited
max_agent_hops: 3 # maximum delegation depth
timeout:
llm_call_seconds: 60 # per-call HTTP timeout (--llm-timeout)
llm_max_retries: 3 # transient-error retries per call (--llm-max-retries)
chain_seconds: 60 # wait for delegate reply before upstream error
mcp_probe_seconds: 5 # per-server MCP tools-list probe timeout (#3475)
on_limit:
mode: interactive # interactive | unattended | auto_extend
auto_extend_times: 1 # (auto_extend mode) number of auto-extensions
ask_timeout_seconds: 0 # (interactive mode) user-prompt timeout; 0 = wait forever
threat_scan:
enabled: true # content-layer prompt-injection scan + fence
fail_open: true # scanner error → allow (FN tolerated over FP)
fence_enabled: true # structurally fence untrusted content as data
block_severity: block # min severity that blocks at write seams: block | warn
custom_patterns: [] # operator [regex, id, scope, severity] extensions
capability_narrowing: off # OPT-IN capability narrowing while external content is live: off | turn | iteration
spawn:
max_depth: 10 # max LLM spawn-lineage chain depth (spawn_agent); 0 = unlimited
max_children: 20 # max fan-out: direct children per parent AND topology size; 0 = unlimited
max_pipeline_fan_out_depth: 5 # max pipeline for_each fan-out NESTING depth; 0 = unlimited
max_pipeline_spawns: 100 # max ephemeral sessions ONE pipeline run may spawn; 0 = unlimited
safety.loop fields¶
| Path | Type | Default | CLI flag | Description |
|---|---|---|---|---|
safety.loop.max_router_calls_per_turn |
int | 3 |
— | Chat-router invocations per user turn. 0 = unlimited. |
safety.loop.max_router_iterations |
int | 5 |
--max-iterations |
Maximum LLM tool-call iterations per user turn. CLI --max-iterations overrides when provided; reyn run-once uses CLI default of 80. |
safety.loop.max_tool_calls_per_turn |
int | 50 |
— | Cost-bound: maximum tool_calls honoured from a SINGLE LLM completion. A degenerate completion can emit thousands (observed 3451); the OS processes only the first N, drops the overflow, and appends a re-grounding notice. 0 = unlimited. |
safety.loop.max_hook_driven_turns |
int | 25 |
— | Loop valve: caps hook self-continuation. Each hook-originated (kind="hook") turn counts 1; the counter resets on each human user turn. When the count would exceed the cap the next hook turn hits the safety.on_limit checkpoint (warn → ask_user → abort) instead of running — a backstop that does not obstruct intentional loop-engineering. 0 = unlimited. |
safety.loop.max_agent_hops |
int | 3 |
— | Maximum delegation depth (user → A → B → C = 3 hops). |
safety.timeout fields¶
| Path | Type | Default | CLI flag | Description |
|---|---|---|---|---|
safety.timeout.llm_call_seconds |
float (s) | 60 |
--llm-timeout |
Per-call HTTP timeout passed to LiteLLM. |
safety.timeout.llm_max_retries |
int | 3 |
--llm-max-retries |
Transient-error retries per LLM call (LiteLLM exponential backoff). |
safety.timeout.chain_seconds |
float (s) | 60 |
— | How long a multi-agent chain waits for a delegate reply before synthesising an error. 0 = disabled. |
safety.timeout.mcp_probe_seconds |
float (s) | 5 |
— | Per-server timeout for the MCP tools-list probe (ensure_mcp_tools_cached / reyn mcp refresh, #3475). A server slower than this is not cached at all (#3520 — a timed-out probe produced no answer, so nothing is stored; it used to be stored as an empty tool list, which the model read as "this server has no tools" for the rest of the session and across restarts) and is re-probed on the next turn; an mcp_tool_probe_degraded audit-event names the server and reason (timeout / exception). Raise under co-located CPU load — that is also how you stop paying a re-probe every turn on a legitimately slow server. |
safety.on_limit fields¶
| Path | Type | Default | Description |
|---|---|---|---|
safety.on_limit.mode |
string | interactive |
What happens when a loop/timeout cap fires. interactive (default) — prompt the user via ask_user for permission to extend; headless paths short-circuit cleanly to abort. unattended — abort immediately on hit (opt-in for CI / cron / scripted runs that cannot pause). auto_extend — auto-extend auto_extend_times times then abort. |
safety.on_limit.auto_extend_times |
int | 1 |
Number of auto-extensions before falling through to abort. Used only when mode: auto_extend. |
safety.on_limit.ask_timeout_seconds |
float (s) | 0 |
How long interactive mode waits for a user response. 0 (default) = wait forever; positive = abort with partial data after the window elapses. |
safety.threat_scan fields¶
Content-layer threat defense: inspects untrusted content for prompt-injection before it enters the system prompt / context, complementing the execution layer (permissions / sandbox). Defense-in-depth = a structural fence (mark untrusted content as data) plus a pattern scan backstop.
| Path | Type | Default | Description |
|---|---|---|---|
safety.threat_scan.enabled |
bool | true |
Master switch. Default-on: content→context (read) seams detect non-blocking + emit telemetry; agent-write seams block. |
safety.threat_scan.fail_open |
bool | true |
Scanner error → allow (a false-negative is tolerated over a false-positive that would wedge a turn). |
safety.threat_scan.fence_enabled |
bool | true |
Structurally fence untrusted content (random-id markers + control-token strip + unicode normalization) so the LLM treats it as data, not instructions. For which content this applies to, see Security: what gets structurally fenced. |
safety.threat_scan.block_severity |
string | block |
Minimum severity that BLOCKS at agent-write seams (memory write / skill install). block = only block-severity patterns; warn = warn-severity also blocks (stricter). |
safety.threat_scan.custom_patterns |
list | [] |
Operator pattern extensions, each [regex, id, scope, severity]. Merged into the built-in catalog for scans. |
safety.threat_scan.capability_narrowing |
string | off |
OPT-IN (#3501). The CAPABILITY half of the same defense: while external_source-tagged content is live in the active context, apply the _untrusted capability profile (deny memory-write / re-delegation / exec / MCP-, skill-, pipeline-install / spawn / pipeline-run). One ordered ladder, not an enable flag plus a granularity flag. off (default): the narrowing never engages — an agent keeps the capabilities it started the session with, whatever enters its context. turn: resolved at each turn boundary, so external content arriving mid-turn narrows from the NEXT turn. iteration: additionally re-resolved at every router-loop iteration, so content arriving in round N narrows round N+1 of the SAME turn (closes the same-turn injection window) at the cost of a legitimate external→privileged flow being narrowed mid-flow; monotonic within the turn (a turn-scoped latch survives a compaction evicting the tainted entry, so the taint cannot be laundered) and emits an untrusted_narrowing_engaged audit-event the first time it engages in a turn. An invalid value is rejected at config load rather than silently resolving to a level you did not ask for. |
safety.spawn fields¶
Operator bounds on the LLM spawn tree — a DoS guard so an agent cannot mint an unbounded org. Set in reyn.yaml (the restart-only OUT layer): an LLM has no runtime path to raise its own base limit. Enforced at the LLM spawn seams (spawn_agent, create_topology); the operator CLI create path is unbounded (authority). Defense-by-default (non-zero) — there is no backward-compat spawn tree to break.
When a spawn would exceed a limit, the safety.on_limit checkpoint fires — the same mode-driven framework used by loop and budget caps:
interactive(default): prompts the operator for approval to extend. On approval, the extension is recorded per-spawner so the same scope does not re-prompt. The base config limit is unchanged; any extension is operator-approved, never LLM-driven.unattended: rejects immediately (no prompt possible — CI / scripted runs).auto_extend: auto-approves up toauto_extend_timesextensions, then rejects.
max_depth and max_children carry separate per-spawner extension keys so an operator-approved increase in one does not silently widen the other.
| Path | Type | Default | Description |
|---|---|---|---|
safety.spawn.max_depth |
int | 10 |
Maximum spawn-lineage chain depth (operator-top = 0; each spawn_agent +1). Exceeding this fires the safety.on_limit checkpoint. 0 = unlimited. |
safety.spawn.max_children |
int | 20 |
Maximum fan-out: governs BOTH the direct spawn-children per parent (spawn_agent) AND the member count of a create_topologyd topology (org size). Exceeding this fires the safety.on_limit checkpoint. 0 = unlimited. |
safety.spawn.max_pipeline_fan_out_depth |
int | 5 |
Pipeline fan-out NESTING bound: the max depth of nested for_each scopes (a top-level for_each = 1; a for_each inside another's do/collect = 2; …). A for_each exceeding this FAILS the step (bounded-by-construction; no on_limit prompt — pipeline runs are non-interactive). Distinct from max_depth (spawn lineage): a pipeline agent-step carries no lineage, so max_depth does not cover fan-out. 0 = unlimited. |
safety.spawn.max_pipeline_spawns |
int | 100 |
Pipeline spawn-COUNT bound: the max ephemeral sessions ONE pipeline run may spawn across all its agent steps (top-level or fanned out via for_each). A per-run monotonic counter; the spawn past the cap FAILS the step. The ONLY spawn-count enforcement for lineage-less pipeline agent-steps (max_children does not cover them). 0 = unlimited. |
See safety.on_limit fields for the mode settings.
tool_use block¶
Chat-layer tool-use scheme x transport selector (FP-0066 P4b, #3247). Tool-use
decomposes into two orthogonal axes: scheme is the presentation — how
capabilities are shown/discovered to the LLM (category / enumerate-all /
retrieval) — and transport is how the model expresses the chosen action
(tool_calls / content_fence). The resolved (scheme, transport) pair
selects a registered ToolUseScheme — a pluggable mechanism for how tools
are presented to and dispatched from the LLM.
tool_use:
scheme: enumerate-all # default
transport: tool_calls # default
universal_wrappers_enabled: true # default; set false to opt out
| Key | Type | Default | Description |
|---|---|---|---|
scheme |
string | enumerate-all |
Presentation for the top-level chat layer: category / enumerate-all / retrieval. Default enumerate-all — flat-lists actions so the LLM invokes them directly instead of hallucinating invoke_action names (raised direct tool-use ~30%→100%). Set to universal-category for a minimal-surface / many-tool catalog (discover-then-call). |
transport |
string | tool_calls |
How the model expresses a chosen action: tool_calls (native tool-calling) or content_fence (the action is expressed as fenced code in the reply text — CodeAct). |
universal_wrappers_enabled |
bool | true |
#4552 PR-3 — moved here from action_retrieval.universal_wrappers_enabled (architect's ruling: a tool_use/presentation-scheme property, not a retrieval setting). For a layer whose scheme resolves to universal-category, true (default) exposes only the 4 universal wrappers (list_actions, search_actions, describe_action, invoke_action) in that layer's tools=. Legacy per-kind tools (invoke_skill, call_mcp_tool, etc.) are no longer surfaced to the LLM on that layer but remain available as wrapper backing handlers. search_actions is gated separately by embedding.enabled (#4564 — this flag has NO effect on search_actions visibility in any scheme). Set false to disable the wrapper surface entirely for that layer (= legacy tools become the only addressing path again). Does not affect a layer whose scheme is enumerate-all/retrieval — those never consult this flag. Setting it explicitly true while scheme isn't universal-category has no effect; reyn config validate reports that combination (#4231(C)). |
See Concepts: universal catalog for the full list_actions / describe_action / invoke_action wrapper semantics (category discovery, error-recovery suggestions, weak-model landing design).
Every combination of the axis values above is implemented today:
scheme \ transport |
tool_calls |
content_fence |
|---|---|---|
category |
universal-category |
code-API over the wrappers |
enumerate-all |
enumerate-all (default) |
CodeAct |
retrieval |
retrieval |
search-first code-API |
A pair that is not in this table — a scheme or transport name reyn does not
have — raises a legible error at config-parse time rather than silently falling
back or being accepted. CodeAct is reached via scheme: enumerate-all +
transport: content_fence — it is the same full flat catalog as
enumerate-all, expressed as fenced code instead of native tool calls, not a
scheme name of its own. retrieval additionally requires embedding.enabled:
true (FP-0066 §7).
scheme: category + transport: content_fence gives the small-surface
counterpart of CodeAct: the model writes fenced Python, but the functions it is
shown are the catalog wrappers (list_actions / describe_action /
invoke_action) plus the base tools — so the system prompt does not grow with
the catalog the way CodeAct's does. A call reads
result = invoke_action(action_name="read_file", args={"path": "README.md"}).
Choose it when a weak / low-cost model does better writing code than emitting
JSON tool calls and the catalog is large enough that listing every action up
front costs too much — CodeAct (enumerate-all + content_fence) gives up the
second. Every in-code call still passes the same exclude + permission gate as the
equivalent JSON call, plus sandbox containment.
scheme: retrieval + transport: content_fence is the search-first code-API:
the functions the model is shown are search_actions / describe_action /
invoke_action plus the base tools, with no list_actions — discovery here
is a search, not a listing. A turn reads
hits = search_actions(query="read a file")
result = invoke_action(action_name="read_file", args={"path": "README.md"})
It differs from the tool_calls retrieval cell in cost, not in paradigm: there,
narrowing takes a round trip (the OS re-presents the matched actions as a new
tools= payload, because a payload can only change between calls); here the
search result is an ordinary value inside the snippet, so the search and the call
can happen in one turn. Choose it over category + content_fence when the
catalog is large enough that browsing it by category is the wrong entry point and
you would rather the model describe what it wants. If the embedding index is not
ready yet, this cell falls back to listing the flat catalog rather than showing a
search that would return nothing.
The old single tool_use.chat key is removed (clean-break, no compat
alias). A reyn.yaml still carrying tool_use.chat fails loud at config-load
time naming the scheme / transport migration — it is never silently
ignored. A former chat: codeact becomes scheme: enumerate-all +
transport: content_fence; a former chat: universal-category becomes
scheme: category (transport stays at its tool_calls default) — category
is the presentation-axis name, and it resolves to the registered
universal-category scheme.
A scheme owns how the tools= payload is built, the SP tool-use instructions, how an LLM response is interpreted, and how it is dispatched — so swapping scheme / transport changes the whole tool-use loop for the chat layer without OS changes.
For what each scheme does and when to choose which (enumerate-all / retrieval / CodeAct vs the default), see Tool-Use Schemes.
web_fetch block¶
SSL settings for the web_fetch tool and the MCP package registry.
Renamed from web.fetch: (#4174 T4) — the old web: key conflated this
(the web_fetch TOOL's own settings) with the unrelated reyn web GATEWAY's
own settings (now gateway:, below). web: never named which of the two
an operator was reading or writing.
web_fetch:
verify_ssl: true # true | false | omit (default: env-var chain)
ca_bundle: /path/to/ca-bundle.pem # optional custom CA bundle
max_download_bytes: 10485760 # wire-byte ceiling (default 10MB)
allow_private_ips: false # SSRF: opt-in to private IPs (default deny)
Priority chain (highest first):
| Priority | Condition | Effective SSL config |
|---|---|---|
| 1 | web_fetch.ca_bundle set |
Custom CA bundle file (verify=<path>) |
| 2 | web_fetch.verify_ssl: false |
Disable SSL verification (verify=False) — use only in controlled environments |
| 3 | web_fetch.verify_ssl: true |
Force SSL verification (verify=True) |
| 4 | Both unset | Fall through: SSL_VERIFY env var → litellm.ssl_verify → SSL_CERT_FILE → True |
verify_ssl and ca_bundle also apply to MCP registry HTTP calls (package install).
| Field | Type | Default | Description |
|---|---|---|---|
web_fetch.max_download_bytes |
int | 10485760 (10MB) |
Maximum response bytes web_fetch reads off the wire. A response whose Content-Length exceeds this is rejected before any body is downloaded; a chunked / unknown-length body is aborted once the stream passes the ceiling (status too_large). Guards against an unbounded-body memory blow-up from a hostile or runaway URL. <= 0 or non-integer falls back to the default. |
web_fetch.allow_private_ips |
bool | false |
SSRF opt-in. When true, web_fetch / safe.http may fetch private RFC1918/ULA addresses (enterprise internal-fetch). Link-local, cloud-metadata (169.254.169.254), and loopback are always denied regardless of this flag. HTTP redirects are re-validated per hop (both the host allowlist and the IP-deny), so an allowlisted host cannot redirect to an internal target. Also exported to the REYN_FETCH_ALLOW_PRIVATE_IPS env var so the safe.http subprocess and registry clients honor the same opt-in. |
ℹ️ #4274:
web_fetch.*now reaches every live chat session'sweb_fetchop execution (SessionFactoryConfig.web_fetch_config→Session→ the routerOpContext). Before this landed, the block parsed and validated clean but never reached a realweb_fetchcall — a pre-existing gap the #4174 T4 rename did not itself fix or worsen. An operator who set a non-default value (e.g.verify_ssl: falseorallow_private_ips: true) and never noticed it doing nothing will now see it actually take effect.
gateway block¶
The reyn web gateway's own settings — authentication model, WebSocket
inbound-frame ceiling, and per-surface mount overrides.
Split from web: (#4174 T4) — see web_fetch above for the other half
that key used to conflate.
gateway:
ws_max_size: 16777216 # WS inbound-frame ceiling (default 16MB)
default_design: coral # OpenUI host's default design slug (unset → env → first alphabetically)
auth:
token: my-shared-secret # T3 cross-machine bearer token (required for a non-loopback bind)
require_token_on_loopback: true # also require the token on loopback TCP (secure default)
tls_certfile: /path/to/cert.pem # operator TLS cert (T3); omit → self-signed TOFU
tls_keyfile: /path/to/key.pem # operator TLS key (T3); set together with tls_certfile
| Field | Type | Default | Description |
|---|---|---|---|
gateway.ws_max_size |
int | 16777216 (16MB) |
Maximum size (bytes) of a single inbound WebSocket frame the reyn web gateway accepts; a larger frame is rejected by the server before delivery. Pins the WebSocket frame ceiling explicitly instead of relying on the server library's implicit default, so the bound stays in place across server-library upgrades. Operators may tighten or loosen it. <= 0 or non-integer falls back to the default. |
gateway.default_design |
str | null |
The OpenUI host's default design slug, served by GET /api/web/config (#4317). Resolution priority: REYN_WEB_DEFAULT_DESIGN env var → this field → first available design alphabetically. Was web.default_design pre-#4174-T4; T4's split enumerated ws_max_size/auth/surfaces but dropped this field entirely, leaving it with no address in the typed schema for a full cycle — #4317 gave it one. A genuine behavior change, not a pure bugfix: the old key was read via a raw yaml.safe_load of reyn.yaml that bypassed the loader/schema entirely, so it kept resolving web.default_design: correctly the whole time despite the schema no longer knowing that key — an operator still on the old key now gets nothing here (falls through to the next priority level) instead of the silently-surviving old value. reyn config validate/migrate surface the rename via the "web" RenamedKeyHint, alongside auth/ws_max_size/surfaces. |
gateway.auth.token |
str | null |
The gateway's cross-machine (T3) bearer token. A non-loopback bind refuses to start without it (fail-closed — closes the accidental-exposure hole). A loopback bind generates an ephemeral token at startup when this is unset (printed in the launch URL, Jupyter-style), so no gateway surface is ever left unauthenticated. The token gates every functional surface uniformly — the AG-UI chat routes, /api, /a2a, /mcp, and the resource-fetch routes — not the AG-UI surface alone. |
gateway.auth.require_token_on_loopback |
bool | true |
When true, even loopback TCP connections must present the token (secure default — a shared multi-user host must not leave the browser loopback surface open). Same-machine UDS connections are authenticated by OS peer credentials and never need a token. |
gateway.auth.tls_certfile |
str | null |
Operator TLS certificate (PEM) for a T3 network bind. When unset, a self-signed certificate is generated at startup and its SHA-256 fingerprint is printed for trust-on-first-use pinning. Must be set together with tls_keyfile. |
gateway.auth.tls_keyfile |
str | null |
Operator TLS private key (PEM) paired with tls_certfile. Setting only one of the two is a startup error. |
Transport tiers (secure-by-default). The gateway identifies every connection: T1 in-process (the operator's own process, no auth); T2 same-machine cross-process over a UNIX domain socket (reyn web --uds PATH) identified by OS peer credentials, or loopback TCP as a fallback; T3 cross-machine network, which requires gateway.auth.token and runs over TLS. An intervention answer is a permission grant, so an unauthenticated connection cannot answer.
gateway.surfaces: per-surface opt-in/opt-out (FP-0058 P2)¶
reyn web hosts several surfaces on the one gateway process; each can be
independently enabled or disabled. Secure-default: AG-UI, the web UI
(OpenUI shell + /web/designs), the REST /api control plane, /health,
and the resource-fetch routes (/agents/*/tool-results/*) are ON — the
operator's own browser/CLI need them to function at all. A2A and MCP
are OFF by default — they are broad machine-integration ports (peer
agents / external LLM clients reaching into this process), so they require
explicit opt-in.
gateway:
surfaces:
a2a:
enabled: true # opt in to the Agent2Agent JSON-RPC surface
mcp:
enabled: true # opt in to the MCP-over-SSE surface
| Surface | Secure-default | What it hosts |
|---|---|---|
agui |
ON | The AG-UI SSE transport (chat, self-gated per-handler). |
webui |
ON | The OpenUI shell (/, /static/*) and /web/designs/*. |
health |
ON | GET /health. |
api |
ON | The REST /api control plane (agents / topologies / permissions / budget / web-config / web-data), auth-gated operator class. |
resources |
ON | /agents/<agent>/tool-results/<artifact>, auth-gated resource class. |
a2a |
OFF (opt-in) | The Agent2Agent JSON-RPC spine, auth-gated peer class. |
mcp |
OFF (opt-in) | MCP-over-SSE (/mcp/sse, /mcp/messages), auth-gated client class. |
Also settable per-surface from the CLI — reyn web --enable a2a --enable mcp
or reyn web --disable api (repeatable per-surface flags, not a comma-list).
Precedence: CLI --enable/--disable > gateway.surfaces config > the
secure-default table above. This is launch-time-only, operator-owned
config — read once when reyn web boots, never hot-reloadable and never
LLM-settable (the LLM has no launch authority over which surfaces this
gateway hosts). The webhook plugin surface (webhooks.yaml) is unrelated to
this table and keeps its own separate, pre-existing opt-in.
hooks block¶
Agent-lifecycle hooks — a thin operator layer over the unified inbox
and the P6 lifecycle. A list of entries; each fires at a lifecycle point
or an external-event point (on), optionally narrowed by matcher, and
carries exactly one of four mutually-exclusive schemes:
template_push— inject an attributed[hook:<name>]message from a config Jinja2 template.exec— run an argv as a pure side-effect (output IGNORED). Renamed fromshell_execin #3226 Phase 4 (naming honesty, not a security change — this scheme never ran/bin/sh -c <string>; it always executed a tokenized argv withshell=False). Payload is argv-list-only (a clean break from the pre-Phase-4 shell-command string — no compat alias).exec_capture— run an argv whose stdout is a JSON push-directive, pushed via the same path astemplate_push(the only difference is the directive's source: captured stdout vs a Jinja2 render). Renamed fromshell_pushin #3226 Phase 4, same argv-list-only payload.pipeline_launch— launch a registered pipeline with input rendered from the event's template vars, async/detached.
Hooks never silently mutate tool results; pushes are new, attributed, evented messages.
hooks:
- name: next_step # optional → the [hook:next_step] attribution (absent → the point)
on: turn_end # turn_start|turn_end|session_start|session_end|mcp_resource_updated|file_changed|cron_fired|webhook_received
template_push:
message: "Turn complete — consider the next step."
wake: false # false = passive context (C); true = start a turn (E)
push_when: "true" # optional Jinja2 → bool; false skips the push
- on: session_start
exec: ["touch", "/tmp/reyn-session-started"] # argv only — no shell redirection (">>")
- name: dynamic # stdout decides whether/what/how to push
on: turn_end
exec_capture: ["scripts/decide-next.sh"] # emits {"push_when":true,"wake":true,"message":"..."}
- on: mcp_resource_updated # external-event point — fired by a subscribed MCP resource
matcher: {server: "github", uri: "file:///repo/docs/**"}
pipeline_launch:
name: reindex_docs
input_template: {uri: "{{ uri }}"}
- on: cron_fired # external-event point — a message-based cron job fires
matcher: {job_name: "backup"}
exec: ["touch", "/tmp/reyn-backup-ran"]
- on: turn_end # per-hook sandbox knobs (exec schemes only). The
exec: ["npm", "run", "lint"] # agent-level `sandbox.policy` does NOT reach a hook
subprocess: true # exec argv — these keys are where a hook's sandbox is set.
network: true # omit any of them → that axis stays at the hook floor
write_paths: ["/tmp/lint"] # (no fork / no network / no writes)
- on: webhook_received # external-event point — an inbound webhook resolves to a session
matcher: {transport: "slack"}
template_push:
message: "New Slack message routed in."
wake: false
- on: turn_end # `include` appends a field's raw value after `message`,
template_push: # fenced and never Jinja2-rendered (proposal 0067 P2)
message: "Turn complete — the user's last message is attached below."
wake: false
include: ["user_text"]
| Key | Type | Default | Description |
|---|---|---|---|
on |
string | required | Lifecycle point (turn_start, turn_end, session_start, session_end), an external-event point — mcp_resource_updated (a subscribed MCP resource changing), file_changed (a watched path changing, requires fs_watch), cron_fired (a message-based cron job fires), or webhook_received (an inbound webhook resolves to a session) — or a composed:<name> composed-event kind (an OPEN namespace, one entry per composers: config's emit.kind; a composed→wake chain is bounded by the same max_hook_driven_turns loop-valve as any other hook-driven turn). cron_fired/webhook_received are non-blocking relative to their ingress — dispatch never delays the cron job's delivery or the webhook's HTTP response. See Concepts: hooks § External-event points. |
name |
string | the point | Optional operator label surfaced as the [hook:<name>] attribution prefix on a push. Absent → defaults to the hook-point (e.g. [hook:turn_end]). |
matcher |
map[string,string] | none | Optional filter, evaluated against the firing event's template vars before the hook's action runs. Every named field must match: exact string equality, except uri/path (shell-style glob via fnmatch). Absent/empty → the hook always fires (unaffected for lifecycle hooks, which carry no server/uri/path). Validated at load for the builtin hook points: a matcher field name outside that point's builtin schema (e.g. a typo, or a lifecycle point's matcher naming server/uri) is a HookConfigError at config-load time, not a silently-dead matcher. A future/custom point with no builtin schema entry keeps the pre-validation behavior — a field the event doesn't carry never matches at runtime. |
template_push |
map | none | Inbox-push hook from a Jinja2 template (one of the four schemes). message (Jinja2 → text), wake (bool/Jinja2, default true: true starts a new turn = self-continuation; false rides along with the next turn as passive context), push_when (Jinja2 → bool, default true; false skips), session (parsed + carried; naming a different session routes the push to that session's inbox — cross-session push; omitted or the current session → the local path), include (list of hook-event field names, default [] — each is appended to message VERBATIM, fenced and attributed, never through Jinja2; a name the firing event doesn't carry — a typo, or a field this hook point doesn't have — is written as (absent)). message may only interpolate context_safe fields (today, every builtin field is — include is the door for a future non-context_safe field to still reach the pushed text as content, without giving it template control flow). |
exec |
list[string] | none | An argv run as a pure side-effect (one of the four schemes; renamed from shell_exec in #3226 Phase 4 — argv-list-only, a clean break from the pre-Phase-4 shell-command string). Non-empty list of non-empty strings. Sandbox-gated + consent-allowlisted; stdout/stderr are logs, never parsed. |
exec_capture |
list[string] | none | An argv whose stdout is a single JSON object {"push_when": bool, "wake": bool, "message": str, "session"?: str} (first three required), pushed via the same path as template_push. Renamed from shell_push in #3226 Phase 4 — argv-list-only, same clean break as exec. stdout must be pure JSON (logs → stderr). Sandbox-gated + consent-allowlisted. Any failure (non-zero exit, invalid JSON, missing/wrong-typed field) skips the push (fail-safe). |
subprocess |
bool | false (the floor) |
exec / exec_capture only — may this hook's argv spawn child processes? Declaring it on another scheme is a HookConfigError (a silently-ignored security field would read as an applied restriction that was never applied), as is a non-bool value. Omitting the key keeps the false floor; only an explicit true/false is your expressed will. Set true when the command forks: a bare command that resolves to a version-manager shim (pyenv/asdf/mise) or a spawn-based launcher (npx/uvx) forks internally, so it is denied under the default even though the command itself never forks — the symptom is an opaque fork: Operation not permitted (now logged with denial_class=fork_denied naming it an environment/config problem). Using an absolute path to the real binary is the alternative. Unlike a stdio MCP server's subprocess: (default true — such a server forks to exist), a hook's fork need depends on your own command, so there is no safe blanket default: the judgment is per hook. Note the agent's own hooks_add tool can only create template_push hooks, so exec/exec_capture (renamed from shell_exec/shell_push in #3226 Phase 4) — and this knob — remain operator-owned. |
network |
bool | false (the floor) |
exec / exec_capture only — may this hook's argv reach the network? Same rules as subprocess above: declaring it on another scheme or giving it a non-bool value is a HookConfigError, and omitting it keeps the false floor (only an explicit true/false is your expressed will). Set true for a hook that posts to an API or pulls from a registry. Note that the agent-level sandbox.policy does not grant this — see the boundary note under that block. |
write_paths |
list[string] | [] (the floor) |
exec / exec_capture only — filesystem paths this hook's argv may write (~ expanded; write implies read). Same rules as subprocess above; omitting the key keeps the floor, which grants no writes, while an explicit list — including [] — is your expressed will. Keep the scope tight: grant the specific directory the hook writes, never ~. A hook shell's floor carries no sensitive-read deny-list by default (#3901 — read_deny_paths's dataclass default is now empty; the MCP server default is a deliberate opt-in exception, not the hook floor). Not granted by the agent-level sandbox.policy — see the boundary note under that block. |
pipeline_launch |
map | none | Launch a registered pipeline (one of the four schemes). name (required — the pipeline's registered name; unregistered → warns and skips the launch, the hook point still completes), input_template (optional — a dict's string leaves are each Jinja2-rendered against the event's template vars; a plain string is rendered once and its output parsed as a JSON object; omitted → input=None). Async/detached: the result arrives later on this session's own inbox as a pipeline_result message. |
wake / push_when truthiness, and why a typo fails differently
depending on the field. A rendered wake/push_when/session string
is converted to bool by case-insensitive lookup: true/1/yes/on →
True; false/0/no/off/empty-string → False. Any other value is a
render error. message uses strict undefined-variable checking — a
typo'd variable name raises, and the whole push is skipped (loud
failure); wake, push_when, and session use silent checking — a
typo'd variable there renders as an empty string, which resolves to
False/None (quiet, fail-safe: don't wake, don't push, or fall back to
the current session on a broken condition). Any render failure at all —
bad Jinja2 syntax, an unrecognised truthiness string, or message's
strict-undefined error — is caught: the push is skipped
(push_when=False) and logged at WARNING, never crashes the turn.
composers block¶
Event correlation (Hook-Event Redesign Phase 4b/5, proposal
0059 §5/§9). A
Composer subscribes to this session's per-process HookBus
(independent pub/sub broadcast of every hook-event — NOT the P6
audit-event/WAL-event stream), buffers matching events per its op, and —
once the op's condition is met — publishes ONE new event with
kind = "composed:<name>" back to the same bus. A composed:<name> kind
is then a normal hooks: on: target (hooks block above)
— subscribing a Sync side-effect to the composition's output.
composers:
- name: deploy_approved
op: all
inputs:
- { kind: builtin:external:mcp_resource_updated, match: { server: "github" } }
- { kind: mcp:approval-server:approved }
policy: { capacity: 10, overflow: reject, ttl: 5m }
emit: { kind: composed:deploy_approved }
- name: job_overdue
op: deadline
on: mcp_resource_updated
matcher: { uri: "orch://job/*/started" }
until:
on: mcp_resource_updated
matcher: { uri: "orch://job/*/done" }
correlate_by: job_id
ttl: 1800
| Key | Type | Default | Description |
|---|---|---|---|
name |
string | required | The composer's identifier — also the correlation-key namespace and the composer_fired/composer_dropped P6 event's composer field. |
op |
string | required | One of all (every input arrives, per key), any (first matching input, stateless), seq (inputs' kinds arrive in the configured order), window (fires ttl seconds after the first matching event, with everything buffered), debounce (fires ttl seconds after the last matching event with no newer one in between), correlate_by (like all, keyed by a payload field), count (fires once count matching events arrive, per key), deadline (issue #3166 — fires when its until pattern does NOT arrive within ttl of its on pattern, per key; see below). |
inputs |
list[map] | required (all ops except deadline) |
Each entry: kind (a hook-event kind — a builtin builtin:lifecycle:*/builtin:external:* kind, or any other kind observed on the bus) + optional match (a payload field→pattern filter, same semantics as a hook's matcher). source is NOT settable — every bus event carries source="builtin" (kind + payload already encode the source type/instance); naming any other source value is a load-time error. |
on / matcher |
string / map | required for op: deadline |
deadline's arm pattern, in place of inputs — on is the kind, matcher the optional payload filter (same shape as inputs[].kind/match). |
until |
map | required for op: deadline |
The disarm pattern — {on, matcher}, same shape as the top-level on/matcher. If until does not arrive for a given key within ttl of on arming that key, the composer fires; if it arrives first, the key is silently disarmed and never fires. |
policy |
map | {capacity: 10, overflow: drop_oldest, ttl: 5m} |
capacity (max concurrent pending correlation keys), overflow (drop_oldest/drop_newest/reject — no publisher-blocking backpressure; also bounds a deadline arm-storm), ttl (seconds, or a <N><unit> string with unit s/m/h; an incomplete all/seq/correlate_by/count pending record older than ttl is evicted — for window/debounce, ttl IS the fire timer, and for deadline, ttl is the deadline itself). For deadline only, ttl may also be given at the top level (as in the example above) instead of nested under policy:. |
correlate_by |
string | none | The payload field read as the correlation key (instead of one global bucket). Required when op: correlate_by; optional but typical for op: deadline (e.g. keying a dead-man monitor by job_id so N jobs are watched independently). |
count |
integer | none | Required when op: count — the threshold of matching events before firing. |
emit.kind |
string | required (defaults to composed:<name> for op: deadline if omitted) |
The composed event's kind — MUST start with composed: (namespace-enforced at load time; collides otherwise with the P6 audit-event surface). |
durable |
boolean | true for op: deadline, false otherwise |
Whether this composer's pending state must survive a process crash. true routes it to a full-state snapshot file (<per-session state dir>/composer_pending.json, one atomic rewrite + fsync per pending change) and an armed key comes back with its ORIGINAL arm instant; false keeps the free in-process dict. Setting durable: false on a deadline composer is allowed but emits a load-time UserWarning — a dead-man switch that dies with its process is never a silent posture. |
deadline — the dead-man op. Every other op fires because something
happened; deadline is the only one that fires because something did
not happen within its ttl (a heartbeat that stopped, an approval that
never came, a job that never finished). A deadline fire is not itself an
error signal — it means "the thing I was waiting for is missing", and its
composed payload always carries armed_at (when the key armed),
ttl, and awaited (the until pattern that did not arrive) so an
operator can see why it fired without re-deriving it from raw hook-events.
Internally deadline is not new machinery: it reuses the exact same per-key
PendingStore + TTL sweep + QueuePolicy + correlate_by every other op
uses — the only change is that the sweep FIRES an expired pending record
instead of silently discarding it.
deadline's reliability posture is stricter than every other op's, which
is why it is the one op that defaults to durable: true. For the other seven
ops a crash drops one buffered notification; for deadline it drops the
dead-man monitor itself, at the same time as (and likely for the same reason
as) whatever it was watching. With the default in place, an armed deadline
is restored after a restart with its original arm instant, so a deadline
missed during the downtime fires on the first sweep rather than silently
restarting its clock.
Reliability posture: best-effort by default, durable where it matters. A
Composer's in-flight correlation state is held in memory unless durable:
true (see the key table above), so a partially-matched
all/seq/correlate_by simply never fires after a crash — a deliberate
scope decision (the Bus itself is already lossy under backpressure, so a
Composer built on it cannot promise more). A durable composer instead keeps
its pending set in <per-session state dir>/composer_pending.json, a
full-state snapshot file rewritten atomically on every change and never
derived from the WAL — so it survives WAL truncation structurally (CLAUDE.md's
recovery-feature gate). Every fire emits composer_fired; every drop
(overflow or ttl-eviction) emits composer_dropped — both metadata-only
(composer name + correlation key + reason, never the buffered payload
content).
The composed→wake loop-valve bound. A composed:<name> hook's
wake=true push traverses the exact same inbox kind="hook" path any other
hook-driven wake does, so a self-stimulating composed→wake chain (a
composer whose input is fed by a lifecycle point its own consumer hook's
next turn re-triggers) is bounded by the session's existing
max_hook_driven_turns cap with zero additional bounding logic — see
Concepts: hooks § Async Bus and Composer.
Composers are read from the SAME 4-layer additive combine as hooks:
(reyn.yaml startup ∪ .reyn/config/hooks.yaml runtime ∪ per-agent ∪
per-session) but are startup-only — added/removed composer entries take
effect on the next session start, not via the hooks hot-reload seam (a live
Composer's in-flight PendingStore correlation state has no analogous
reload-time reconciliation yet).
fs_watch block¶
Operator-declared filesystem paths whose changes fire the file_changed
external-event hook (#2608 H4).
Each path is watched recursively. To scope a hook to a sub-tree, give it
on: file_changed plus a matcher: {path: "..."} glob — see the hooks:
block above.
Restart-only (OUT-set) — there is no op or tool verb that lets an agent register or widen a watch; a filesystem-wide change feed is treated as the same class of concern as sandbox policy.
| Key | Type | Default | Description |
|---|---|---|---|
paths |
list[string] | [] |
Directories watched recursively for create/modify/delete events. Empty (the default) → the watcher never starts, byte-identical to a build with no fs_watch: config. |
debounce_seconds |
float | 0.2 |
A burst of events for the same path within this window coalesces into a single file_changed fire (one logical change = one hook fire, not one fire per underlying filesystem event). |
Requires the watchdog package: pip install reyn[fs-watch]. paths
configured without the extra installed logs a warning once and disables the
watcher for that session — the rest of the session is unaffected.
sandbox block¶
Backend selection, unsupported-platform policy, and the agent-level sandbox
policy for sandboxed_exec ops + the OS's in-process file/http gates.
sandbox:
backend: auto # auto | seatbelt | landlock | noop
on_unsupported: warn # warn | error | ignore
mode: compat # compat | strict (#3823) — mode sets only the
# DEFAULT for a policy key left unset below; an
# explicit key here always wins over mode. Not
# "custom" or "off" — see #3823 for why.
policy: # optional — the agent-level (operator) sandbox
# policy, config vocabulary (#3823, decoupled from
# SandboxPolicy's internal field names)
network: true
subprocess: true
allow_write_paths: ["{{workspace}}", "/tmp"]
deny_write_paths: []
deny_read_paths: []
allow_env_names: null # unset = deny-list-only; a list SWITCHES the
# env axis to allow-list semantics
deny_env_names: []
timeout_seconds: 600
ℹ️ Every axis except
allow_write_pathsdefaults to full compat (owner ruling, #3901; config vocabulary per #3823):network/subprocess/deny_read_paths/deny_write_paths/deny_env_namesall start at "nothing extra restricted" — the sandbox's job is bounding what happens behind a permitted action, not re-deciding what the launching shell could already do.allow_write_pathsis the one field that stays closed by default: it is the operator-unknowable value ("this op needs this directory") the kernel backend consumes directly, so there is no safe compat default to fall back to. Settingmode: strictflips every axis exceptallow_write_paths(and read, which has no mode-based default at all) to its closed default — see themoderow below.A
deny_write_pathsentry always wins over an overlappingallow_write_pathsgrant (anddeny_read_pathslikewise over the broad read surface, independently — the two axes are separate fields, each denying only its own axis, #3901). On the Seatbelt backend the deny rules are emitted after theallow_write_pathsallow rules, and SBPL is last-match-wins — so a broadallow_write_pathsentry (e.g.~,$HOME,/) that engulfs a path listed indeny_write_pathsdoes not open it for writing, and the OS emits asandbox_policy_narrowedaudit-event so the narrowing is visible (#2978). If you want a credential location (~/.ssh,~/.aws,~/.gnupg, etc.) protected, list it explicitly indeny_read_paths(anddeny_write_pathsif you also want the write axis covered) — unlike before #3901, this is no longer a default; an operator who wants it back opts in. Still scopeallow_write_pathsto the narrowest directories the process actually needs.
| Key | Type | Default | Description |
|---|---|---|---|
backend |
string | auto |
Enforcement backend. auto lets the OS pick: macOS < 26 → seatbelt (sandbox-exec SBPL), Linux ≥ 5.13 with sandbox-linux extra → landlock (+ optional seccomp-BPF), otherwise → noop (audit-only, no enforcement). Explicit values force a specific backend. |
on_unsupported |
string | warn |
Policy when no OS sandbox backend is available — whether an explicit backend was forced-but-unavailable, backend: auto found no platform backend (the auto path honors this too), OR the selected backend failed its enforcement self-test (it is present but did not deny — such a backend is treated exactly like an absent one). warn logs a WARNING at selection and falls back to noop (default — not silent). error raises RuntimeError (fail-closed — refuse to run AI-generated code unsandboxed; set this where enforcement is required, and it works with the default backend: auto and against a present-but-inert backend). ignore silently falls back. |
mode |
string | compat |
#3823: which DEFAULT the resolved policy uses for a policy key the operator left unset below — never a key the operator wrote explicitly (an explicit allow_X/deny_X or bare bool always wins over mode). compat (default) leaves every axis at its compat default (nothing blocked; audit/events/timeout/cancel-teardown still apply). strict flips network/subprocess/env to their closed default (network off, subprocess denied, env passes nothing) — allow_write_paths is UNAFFECTED by mode (stays the caller-supplied per-op workspace floor, operator-unknowable) and read has no mode-based default at all (no allow_read_paths concept, #1199 — only an explicit deny_read_paths narrows either mode). Allowed: {compat, strict} — not custom (owner ruling: was never a third direction, was the symptom of mode/policy having no composition rule) and not off (expressible as compat with every axis at its compat default). |
policy |
map | none | Agent-level (operator) sandbox policy, in the config vocabulary below (#3823). When set, it is the deterministic policy applied to sandboxed ops and folded into the SandboxLayer of the permission intersection (∩) for the OS's in-process file/http gates, on the network/subprocess/env axes — winning over op-declared fields, so a skill or the LLM cannot widen it. allow_write_paths (and the read/write deny-lists) do NOT participate in that intersection: an operator cannot know in advance what directory an op needs, so the kernel backend consumes them directly (#3901 PR-B ③). Omitted (the default) means no agent-level restriction: the SandboxLayer stays the identity (⊤) and op-level fields govern, exactly as before. Sandbox authorization is an operator/run concern. See sub-keys below. |
sandbox.policy sub-keys¶
When sandbox.policy is present, these are the config-facing vocabulary
(#3823) — <direction>_<axis>_<unit> word order for a path/name-set axis,
bare <axis> for a bool axis, decoupled from SandboxPolicy's own internal
field names/senses. Unknown keys are rejected at config load (never silently
dropped — a dropped key on a security deny-list would read as "nothing to
deny").
| Key | Type | Default | Description |
|---|---|---|---|
network |
bool | true (compat, #3901); false under mode: strict |
Allow outbound network from the sandboxed process. Still participates in the permission intersection alongside subprocess/env (an operator-declared value, not a workspace floor) — a config-allowed host is still denied under network: false (bypass-prevention, #1199 S3.1c-2). Set network: false explicitly to isolate the process. |
subprocess |
bool | true (compat) — positive framing, true = allowed; false under mode: strict |
Whether the process may spawn children (owner decision 2026-07-22, #3202: a UX-blocking axis is opt-in restricted, not deny-by-default). Set subprocess: false to deny process-fork; enforced. |
allow_write_paths |
list[string] | [] |
Filesystem paths the process may write (tight guard) — the one axis that stays closed by default and is UNAFFECTED by mode (operator-unknowable per-op value, no compat floor to fall back to). Write implies read for these paths. A deny_write_paths entry that overlaps a grant here still wins (deny-always-wins, #2978). ~ is expanded. |
deny_read_paths |
list[string] | [] (compat, #3901) |
Sensitive paths to DENY from the broad read surface (defense-in-depth, opt-in) — empty by default; before #3901 this defaulted to 7 OS-level credential locations (~/.ssh, ~/.aws, ~/.gnupg, ~/.config/gcloud, ~/.kube, ~/.docker/config.json, ~/.netrc), now an operator sets that list explicitly to get it back. No mode-based default (there is no allow_read_paths concept, #1199) — strict cannot narrow reads any tighter than compat; only an explicit deny_read_paths narrows either mode. Enforced only on backends that support deny-after-allow rules (Seatbelt); not enforceable on allowlist-only backends (Landlock, which has no read-deny primitive). |
deny_write_paths |
list[string] | [] |
The write axis's own deny-list (#3901), mirroring deny_read_paths. An allow_write_paths entry that overlaps or is a superset of any entry here does not defeat the deny — the deny always wins on Seatbelt (#2978), and a sandbox_policy_narrowed audit-event records the narrowing. Denies ONLY the write axis. |
allow_env_names |
list[string] | null |
null (deny-list-only); [] under mode: strict |
New (#3823): SWITCHES the env axis to allow-list semantics when set to a list — only those names pass through (still intersected with deny_env_names, deny always wins). null/omitted keeps the deny-list-only behavior below. |
deny_env_names |
list[string] | [] (compat, #3901) |
Env-var names to WITHHOLD from the sandboxed process. Empty (the default) means the WHOLE environment passes through, same trust level as the launching shell; list a name here to withhold it specifically. Ignored on the axis once allow_env_names is set as a list (deny is still intersected on top, not bypassed). |
timeout_seconds |
int | 120 (#3903①, 2026-08-11 — was 60) |
The foreground wall-clock timeout used when the LLM's exec call doesn't request an override. Must not exceed max_timeout_seconds, or config load fails. |
max_timeout_seconds |
int | 600 (#3903①) |
The ceiling the LLM's per-call timeout request (exec's optional timeout arg) is checked against — operator-controlled, never a hardcoded value: a request above THIS number is rejected (not silently reduced), naming the actual configured max. Narrowing it below 600 genuinely narrows what the LLM can ask for; the LLM can never widen it. |
max_output_bytes |
int | 10 MiB | Per-stream cap on captured stdout/stderr — output beyond it is drained-and-discarded (the truncated flag is set). |
⚠️
sandbox.policydoes not apply to shell hooks. It governs sandboxed ops and the OS's in-process file/http gates; a hook shell's sandbox is scoped per hook, by that hook's ownsubprocess:/network:/write_paths:keys. This is deliberate — a hook is a small declarative reaction to a lifecycle event, so "no fork, no network, no writes" stays the right floor for it even in a run whose ops are deliberately unsandboxed. It is not silent: if you declare one of those three axes here and a shell hook does not re-declare it, the OS logs a WARNING naming the per-hook key that reaches it and emits asandbox_policy_not_appliedaudit-event (#3005). Setting the key on the hook — to any value — is your decision on that axis and stops the warning.
See Reference: control-ir — sandboxed_exec for the op schema and backend selection details.
agent_id¶
Runtime agent identity for audit trail and HTTP header propagation.
A plain top-level scalar (#4174 T5 — flattened from the old agent: {id:
...} namespace: that block held exactly one field, so the namespace added
indirection without adding structure).
| Field | Type | Default | Description |
|---|---|---|---|
agent_id |
string | reyn/<hostname> |
Stable identifier for this Reyn instance. Stamped onto every P6 event payload as agent_id and injected into outgoing MCP, A2A, and external HTTP requests as the X-Reyn-Agent-Id header (SOC2 / ISO27001 / METI v1.1 audit pattern). Recommended format: reyn/<org>/<role> (operator-defined). An empty string falls back to the default so leaving the field blank does not emit an empty agent_id into events or headers. |
The default reyn/<hostname> gives a fresh install a usable identity without operator action. Override in reyn.yaml when running multi-agent fleets or enterprise deployments that need a stable per-role identifier.
See Concepts: multi-agent — Agent ID propagation for cross-agent tracing and A2A header forwarding.
observability block¶
Opt-in OpenTelemetry (OTLP) export of the P6 audit-event stream to spans,
metrics, and log records. Off by default — with no endpoint the exporter is
never attached and behavior is byte-identical to having no OTEL. It is a lossy,
fire-and-forget downstream: it never writes to .reyn/events or the WAL, so
recovery and replay are independent of it.
observability:
otel:
endpoint: "http://localhost:4318" # OTLP HTTP base URL; "" disables
headers:
Authorization: "Bearer ${OTEL_TOKEN}"
service_name: "reyn"
capture_content: false # SR3: raw prompt/response OFF by default
observability.otel fields¶
| Field | Type | Default | Description |
|---|---|---|---|
otel.endpoint |
string | "" |
OTLP HTTP base URL (e.g. http://localhost:4318). Empty = not attached; the standard OTEL_EXPORTER_OTLP_ENDPOINT env var is honored as a fallback, so OTEL can be enabled purely from the environment. |
otel.headers |
map | {} |
Per-request HTTP headers (auth tokens, tenant ids). Values support ${VAR} env interpolation. |
otel.service_name |
string | reyn |
The service.name resource attribute reported to the collector. |
otel.capture_content |
bool | false |
GenAI content-capture gate. false (default) emits refs and token/cost counts only — never a raw prompt/response body in a span or log. Set true to opt into content capture (only against a trusted collector). |
Requires the OTEL SDK: pip install reyn[observability]. An endpoint configured
without the SDK installed logs once and stays not-attached (fail-open) — the
session is unaffected. Full event→span/metric/log mapping, the pinned GenAI
convention version, and the fail-open / recovery-independence guarantees are in
Reference: observability (OTEL export).
delegation block¶
Cross-agent delegation policy (#2081). Selects the capability floor a delegated agent — one spawned by another agent's delegation, recursively — receives when it is otherwise unbound by a topology capability_profile.
delegation fields¶
| Field | Type | Default | Description |
|---|---|---|---|
delegation.capability_default |
inherit | deny |
inherit |
inherit — a delegate inherits the spawner's capability surface (no extra narrowing; byte-identical to pre-#2081). deny — an unbound delegate is narrowed by the built-in restrictive _delegate profile (dangerous-tool classes denied: re-delegation, side-effect execution, memory-writes, MCP install) unless a topology capability_profile binding re-grants it (the binding replaces the default — composition is most-restrictive-wins and cannot re-grant). The default-deny propagates recursively: a sub-delegate is itself a delegate, so a re-granted coordinator's own sub-delegates are still default-denied (no laundering). |
Only the unbound-delegate fallback is affected. A top-level agent and any topology-bound agent are unchanged. The restrictive floor reuses the same single-sourced dangerous-tool taxonomy as the _untrusted content-narrowing profile; operators may tune it independently via .reyn/capability_profiles/_delegate.yaml.
See Concepts: multi-agent and Concepts: capability profile.
auth block¶
OAuth provider configurations for reyn auth login. Each named entry under auth.providers defines an RFC 8628 Device Authorization Grant provider. Empty by default; the operator declares providers they want to authenticate against.
auth:
providers:
github:
client_id: "${secret:github_oauth_client_id}"
device_authorization_url: "https://github.com/login/device/code"
token_url: "https://github.com/login/oauth/access_token"
scopes: [repo, user]
# client_secret optional — omit for PKCE-only / public clients
client_secret: "${secret:github_oauth_client_secret}"
google:
client_id: "...apps.googleusercontent.com"
device_authorization_url: "https://oauth2.googleapis.com/device/code"
token_url: "https://oauth2.googleapis.com/token"
scopes: [openid, email]
client_secret: "${secret:google_oauth_client_secret}"
# audience: required by some providers (e.g. Auth0)
auth.providers.<name> fields¶
| Field | Required | Description |
|---|---|---|
client_id |
yes | OAuth client identifier issued by the provider. |
device_authorization_url |
yes | Endpoint that returns device_code, user_code, and verification_uri (RFC 8628 §3.1). |
token_url |
yes | Endpoint that issues access and refresh tokens after the user completes authorisation (RFC 8628 §3.4). |
scopes |
yes (list) | OAuth scopes to request. Pass [] if the provider requires no scopes. |
client_secret |
no | For confidential clients. Omit for PKCE-only or public clients — RFC 6749 §2.3.1 permits this for installed apps. |
audience |
no | API audience identifier required by some providers (e.g. Auth0). Omit for providers that do not use it (e.g. GitHub, Google). |
${secret:<key>} values resolve at config-load time from ~/.reyn/secrets.env. Use reyn secret set <key> to store them.
See also:
- Reference:
reyn auth—reyn auth login/list/revokecommands - Concepts: secret handling — OAuth lifecycle and credential scoping
- Concepts: multi-agent — agent identity propagation
cron block¶
Schedule recurring message dispatches. The scheduler runs as part of
reyn web (= started in the FastAPI lifespan) or as a foreground
process via reyn cron run.
cron:
jobs:
- name: morning_news
to: news_agent # target agent name
message: "今日の主要ニュースをまとめて"
schedule: "0 9 * * *" # every day 09:00
enabled: true
- name: weekly_ops_report
to: ops_agent
message: "weekly ops report"
schedule: "0 9 * * MON" # Monday 09:00
enabled: true
Fields¶
name(required) — job identifier, unique within the scheduleto(required) — target agent name; the message is dispatched to its inbox withsender="cron:<name>"attributionmessage(required) — free-form text delivered to the target agentschedule(required) — 5-field cron expression (minute / hour / day-of-month / month / day-of-week)notify(optional) — opt-in unattended notification channelinput(optional, default{}) — extra input dict carried on the jobenabled(optional, defaulttrue) —falsekeeps the entry in configuration but skips scheduling
Legacy skill-based jobs (a bare
skillname) are no longer supported — the skill runtime was removed. An old on-diskcron.yamlcarrying such an entry is warned-and-skipped at load, not rejected.
Cross-references¶
docs/reference/cli/cron.md—reyn cron run/list/statusdocs/guide/for-users/monitor-and-improve-with-cron.md— usingcron.jobstogether with.reyn/events/to wake a monitoring/improvement agent
permissions block¶
Project-wide capability defaults. Per-skill permissions in skill.md override these.
permissions:
exec: deny # deny | ask | allow — pre-approval key for the `exec` tool
# (renamed from `shell` #3226 Phase 3; existing reyn.yaml
# `shell:` keys are a clean break, no alias — rename to `exec`)
file.read: [".reyn/", "src/stdlib/"] # flat dotted key — `file:` nested {read:, write:}
file.write: [".reyn/state/", "reyn/local/"] # is NOT read; PermissionDecl.from_dict()
# only looks at the flat keys shown here.
python: # ONLY read as a LIST of {function, mode} entries (never a
- function: compute # mapping — `{safe: allow}` is silently skipped, 0 bytes
mode: safe # read). The sole use: fail load if any entry says
# `mode: unsafe` (removed). Grants no runtime authority —
# python steps are always sandboxed regardless of this key.
MCP server install is gated the same way — via file.write (declarative path list, as
above) plus http.get (declarative host list) — not a permissions.mcp_install bool.
See "MCP install" below for that shape (a blanket allow/deny scalar, a different
use of the same keys from the list form shown here).
MCP install¶
The legacy permissions.mcp_install: ask | allow | deny bool axis was removed. MCP install is now gated by the same list axes the rest of the OS uses:
# reyn.yaml — install permissions express through file.write + http.get
permissions:
file.write: allow # blanket allow for .reyn/config/mcp.yaml (= install target)
web.fetch: allow # blanket allow for the registry fetch (= legacy alias)
For finer control, the skill's skill.md declares the canonical paths and hosts; startup_guard prompts the operator once per skill+host, and the runtime check is silent after that (= file.write model for paths outside the default zone, http.get per-host).
| Want | New shape |
|---|---|
| Block all installs project-wide | file.write: deny for .reyn/config/mcp.yaml paths, or web.fetch: deny for the registry host |
| Allow installs without prompting | file.write: allow and web.fetch: allow at the project scope |
| Allow only certain hosts | Skill declares http.get: [{host: "..."}] explicitly; wildcard ["*"] defers to per-host prompts |
Enterprise pattern — point reyn at private / corporate registries with declarative config or env-var override:
# reyn.yaml (project scope — committed to git)
mcp:
registries:
- https://mcp-registry.internal.acme.com # private registry (tried first)
- https://registry.modelcontextprotocol.io # public fallback
permissions:
web.fetch: allow # blanket allow for registry fetches
file.write: allow # blanket approval for .reyn/config/mcp.yaml writes
Equivalent env-var override (= wins when both set):
# operator's shell rc / systemd unit / CI runner env
export REYN_MCP_REGISTRY_URLS="https://mcp-registry.internal.acme.com,https://registry.modelcontextprotocol.io"
Both the async op-handler client (reyn.core.registry.client) and the safe-mode skill-internal lookup (reyn.api.safe.mcp.registry) iterate the list in order:
lookup(server_id)returns the first non-404 hit; all 404 →None.search(query)returns the first non-empty result list; all empty →[].
This implements "private first, public fallback" semantics. Legacy singular REYN_MCP_REGISTRY_URL is honored as a one-item list for backward compat.
See Concepts: permission model → "Collapse arc" for the full migration story and the canonical decomposition table.
Legacy
permissions.mcp_installkeys in olderreyn.yamlfiles are accepted with aDeprecationWarningand translate to the equivalentfile.write/http.getgates during the migration window.
The full permission grammar is documented in reference/config/permissions.md. Note this section is about installing an MCP server — granting an already-installed server's tools to be called (permissions.mcp.<server>: allow, including reyn pipe run's auto-grant of configured servers) is a separate axis, covered in reference/config/permissions.md → "Granting an MCP server permission".
${VAR} interpolation¶
Any string field in any section of reyn.yaml (or reyn.local.yaml / ~/.reyn/config.yaml) can reference an environment variable using ${VAR} syntax. Variables are resolved from os.environ at startup, after ~/.reyn/secrets.env is loaded into the environment (see Concepts: secret handling).
# reyn.yaml — ${VAR} works in every string field
llm:
models:
default-sonnet:
model: claude-sonnet-4-5
api_key: ${ANTHROPIC_API_KEY} # LLM API key — resolved from secrets.env or shell
extra_body:
headers:
Authorization: ${LITELLM_PROXY_TOKEN}
api_base: ${LITELLM_API_BASE} # LiteLLM proxy URL (#4174 T3: nests under llm:, not a separate litellm: key)
mcp:
servers:
github:
type: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: ${GITHUB_PERSONAL_ACCESS_TOKEN}
internal_tools:
type: streamable-http
url: https://tools.example.internal/mcp
headers:
Authorization: "Bearer ${INTERNAL_TOOLS_TOKEN}"
Resolution rules:
${VAR}— expands to the env var value; emits a warning and expands to""if undefined (never a hard error).$$— literal$sign (escape).- All string fields in all YAML sections are scanned recursively, including nested dicts and lists.
- Shell environment variables take priority over
~/.reyn/secrets.envvalues.
To manage ~/.reyn/secrets.env, use reyn secret set / reyn secret list / reyn secret clear (see Reference: reyn secret).
API keys¶
API keys and tokens MUST come from environment variables, not from literal values in reyn.yaml. The recommended pattern is:
- Store the value once:
reyn secret set ANTHROPIC_API_KEY - Reference it in
reyn.yaml:api_key: ${ANTHROPIC_API_KEY}
Never paste token values inline in reyn.yaml or reyn.local.yaml — they are committed to git and readable by anyone with repo access.
llm block¶
Proxy / llm.api_base¶
If you route models through a local LiteLLM proxy, put the URL in reyn.local.yaml (gitignored), not reyn.yaml. You can reference an env var here too:
llm.prompt_cache_enabled¶
When true (the default), reyn attaches Anthropic-style cache_control
markers to the system prompt so providers that support prompt caching
(Anthropic, AWS Bedrock Claude) can reuse the prefix across calls. Providers
that don't recognize the marker ignore it (Gemini / OpenAI proxies
pass-through).
Resolution order¶
For each setting, reyn merges these sources, lowest priority first — later layers override earlier:
- Built-in defaults — the values shipped with reyn (e.g.
llm.model: standard). ~/.reyn/config.yaml— user-global.reyn.yaml— project, committed.reyn.local.yaml— project, gitignored (machine-local overrides + values written byreyn config set).<project>/.reyn/config/mcp.yaml— the dynamic MCP server registry. Merged last for themcp.serverssection, so servers added byreyn mcp installoverride anymcp.serversyou hand-edit inreyn.yaml/reyn.local.yaml.<project>/.reyn/config/cron.yaml— the dynamic cron registry. Merged last for thecron.jobssection, so jobs registered at runtime overridecron.jobsinreyn.yamlon a name collision.- CLI flags — applied last, per invocation.
Layers 5 and 6 are scoped: each carries only its own section (mcp.servers / cron.jobs) and is merged section-by-section, so it never touches unrelated settings. ${VAR} interpolation is applied once after all YAML layers are merged, before CLI flags.
Why
.reyn/config/mcp.yamland.reyn/config/cron.yamlwin: these are the runtime-mutable registries (written byreyn mcp installand runtime cron registration) rather than the edit-and-restart static files. Putting them last means a freshly installed server or registered job is the effective entry without the operator also having to touchreyn.yaml.
<project>/.reyn/config.yaml is no longer loaded — it is a deprecated general-config file, not the active .reyn/config/mcp.yaml / .reyn/config/cron.yaml registries above. If it still exists on disk, reyn prints a warning and skips it. Move its contents to reyn.local.yaml, then delete it.
cost block¶
Budget caps and rate limits. All fields are optional; omitting a field (or setting its hard_limit to null) means unlimited.
Each token / cost cap (per_agent_tokens, per_agent_cost_usd, daily_*, monthly_*) is a CostLimitConfig with two sub-fields: hard_limit (the cap; null = unlimited) and warn_ratio (warn threshold as a fraction of hard_limit, default 0.8). Hitting a hard cap refuses the call outright — reyn has no per-dimension ask-for-extension flow today (#4522: an earlier extension_calls field promised one, but its only real implementation was a since-removed subsystem, #2448 — the key is now deprecated and ignored if set). The per-dimension ask_on_exceed bool was removed earlier (subsumed into safety.on_limit.mode, which drives other safety-limit checkpoints — router cap, max hop depth, chain seconds — not the cost caps here).
cost:
# Per-agent caps (in-memory, reset on restart or /budget reset)
per_agent_tokens:
hard_limit: 50000 # refuse after this many tokens for one agent
warn_ratio: 0.8 # warn at 80% of hard_limit (default: 0.8)
per_agent_cost_usd:
hard_limit: 2.00 # refuse after $2.00 spent by one agent
# Per-model rate limit (calls per minute)
rate_limit_per_minute:
openai/gpt-4o: 60
rate_limit_warn_ratio: 0.8 # warn at 80% of rate limit
# Daily / monthly quota (persistent across process restarts)
# Stored in .reyn/state/budget_ledger.jsonl; reset automatically at midnight / month boundary.
daily_tokens:
hard_limit: 100000 # refuse after 100k tokens today
warn_ratio: 0.8
daily_cost_usd:
hard_limit: 5.00 # refuse after $5.00 today
monthly_tokens:
hard_limit: 1000000 # refuse after 1M tokens this month
monthly_cost_usd:
hard_limit: 50.00 # refuse after $50.00 this month
Note: The router call cap (
max_router_calls_per_turn) lives undersafety.loop. See thesafetyblock above.
| Field | Scope | Persists | Reset |
|---|---|---|---|
per_agent_tokens |
per agent | in-memory | /budget reset or restart |
per_agent_cost_usd |
per agent | in-memory | /budget reset or restart |
rate_limit_per_minute |
per model | in-memory (60s window) | automatic (sliding window) |
daily_tokens |
process-global | ledger file | midnight (local time) |
daily_cost_usd |
process-global | ledger file | midnight (local time) |
monthly_tokens |
process-global | ledger file | 1st of month (local time) |
monthly_cost_usd |
process-global | ledger file | 1st of month (local time) |
Cap behavior: when a hard limit is exceeded, the LLM call is refused before it is made. Use /budget to see current usage and /budget reset to clear in-memory counters (daily/monthly are not affected by reset — they are backed by the persistent ledger).
Ledger location: .reyn/state/budget_ledger.jsonl — one record per LLM call, append-only with fsync. This file is not rotated automatically; it grows at roughly a few MB per month and can be manually archived if needed.
cost_warn block¶
High-cost model pre-selection awareness. Surfaces a [⚠ high-cost model: …] marker in the conversation pane when the resolved model's input cost per 1M tokens exceeds the configured threshold. Fires at /model <class> switch and once at session startup. De-duped per session — the same model class is warned at most once per session. Orthogonal to the cost block (= cumulative spend caps) and ContextBudgetAdvisor (= per-turn token ceiling).
Not the same setting as
embedding.cost_warn_threshold(below) — the sharedcost_warnsubstring is a naming coincidence, not a shared concept. This block gates a USD/1M-token model price threshold, read by the chat router at any/modelswitch or session startup, regardless of what the session is doing.embedding.cost_warn_thresholdgates a chunk-count threshold read only inside the embedding/indexing pipeline before a batch embed call. Different unit (USD vs. count), different trigger (model selection vs. indexing), different reader (router vs. embedding pipeline) — placement follows that, not the name: a top-level block for a setting any part of the session can trigger, nested under its owning block for a setting only that subsystem's own code path reads.
cost_warn:
enabled: true
model_threshold_per_1m_input_usd: 5.0 # warn above $5/1M input tokens
block_on_high_cost: false # optional confirm gate (see below)
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Master switch. Set to false to silence all model-cost warnings. |
model_threshold_per_1m_input_usd |
float | 5.0 |
Warn when the selected model's input rate exceeds this value (USD per 1M tokens). Default catches Opus-class (~$15/1M) without triggering on Sonnet-class (~$3/1M). |
block_on_high_cost |
bool | false |
When true, a /model <class> switch to a high-cost model is held for an interactive confirmation and applies only on approval (routed through the shared safety-limit framework, the same one budget-exceed continuation uses). A decline leaves the current model unchanged. A non-interactive session (no TTY) fail-closes — it cannot show the confirm, so the high-cost switch is denied; keep this false to use high-cost models head-less. Session startup stays warn-only regardless of this flag. |
Pricing source: reyn looks up model costs from the LiteLLM pricing database (litellm.model_cost). Models not in the database are treated as below-threshold (no warning). Custom or proxy models that resolve to a key in the database will be matched.
offload block¶
Opt-in switch for all three tool-result size gates (tool-result-schema-redesign §5): the text token cap, the structured-data inline cap, and the media follow-up budget bound. Off by default — every tool result is delivered to the LLM in full, never truncated, never offloaded to a file ref. Offloading a large result to a file ref only helps if the model reads the ref back, and mid-tier models often act on the truncated preview instead, degrading the result — so full delivery is the better default. The LLM-visible format (frontmatter + text) is unchanged either way; only whether size gates truncate varies.
Set enabled: true to opt in when you want the cost reduction of capping/offloading large tool results. When a single tool result is very large, opting in also keeps it from producing an oversized turn.
The size bounds are tunable too, so "cap, but less aggressively" is expressible — previously the only lever was the boolean and every bound was a fixed constant. Each field below defaults to that former constant, so an existing enabled: true config is unchanged, and each falls back independently (setting one does not reset the rest).
offload:
enabled: true # opt in: cap + offload oversized tool results (default: false)
max_inline_bytes: 16384 # ceiling on what a capped text result leaves inline
preview_head_chars: 6000 # how much of the head that inline keeps
preview_tail_chars: 2000 # …and the tail
cap_ceil_tokens: 4096 # upper clamp on the per-turn token cap
cap_alpha: 0.5 # budget-relative term of that cap
structured_inline_max_chars: 2000 # size at which a dict/list gets its own ref
structured_preview_chars: 600 # how much of it stays inline beside the ref
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Master switch. true opts in to the text token cap, the structured inline cap, and the media follow-up budget bound. The size fields below apply only while it is true. |
max_inline_bytes |
int | 16384 |
Absolute ceiling on the inline preview a capped text result leaves behind. Also feeds the turn budget's force-close reserve — it is what the OS reserves for "one more increment", so lowering it lets a turn run longer before force-close fires. |
preview_head_chars |
int | 6000 |
How much of the body's head that inline preview keeps. The body itself is stored and referenced, never lost. |
preview_tail_chars |
int | 2000 |
The same for the tail. |
cap_ceil_tokens |
int | 4096 |
Upper clamp on the per-turn token cap, so a large-context model still gets a lean inline. |
cap_alpha |
float | 0.5 |
Budget-relative term: the cap is min(cap_ceil_tokens, cap_alpha × effective_trigger), which keeps a capped turn compactable on a small-context model too. |
structured_inline_max_chars |
int | 2000 |
Serialized size at which a structured (dict/list) result is stored under its own ref instead of staying inline. |
structured_preview_chars |
int | 600 |
How much of that serialization stays inline beside the ref. |
render_template block¶
Output bounds for the render_template tool. That tool renders a Jinja2 template against structured data into a string; the sandbox blocks template-injection but not resource exhaustion, so a runaway template (e.g. {% for i in range(10**9) %}…{% endfor %}) is capped during generation. The render stops the moment either bound is hit and the result is truncated (with a truncated flag naming which bound fired) rather than flooding memory or hanging. Raise the bounds for a large report; lower them to harden a shared host.
render_template:
max_output_chars: 256000 # streaming char budget — truncate past this
wall_clock_seconds: 5.0 # elapsed-time backstop for a runaway loop
| Field | Type | Default | Description |
|---|---|---|---|
max_output_chars |
int | 256000 |
The streaming character budget. The render truncates the moment cumulative output exceeds it. A non-positive or non-numeric value falls back to the default. |
wall_clock_seconds |
float | 5.0 |
Elapsed-time backstop. Jinja2 exposes no iteration count, so wall-clock bounds a runaway loop that emits little text per step. A non-positive or non-numeric value falls back to the default. |
The defaults are generous enough for real reports / configs and tight enough that a runaway generator stops quickly. Omitting the block leaves both at their defaults (behaviour unchanged).
read_cap block¶
The per-result inline cap for file.read and load_skill (#4381 PR-5, architect design). This is a resource bound, not a budget bound — a role split the codebase draws deliberately: a resource bound protects a fixed physical resource (memory, transfer size) and is measured in bytes, model-independent; a budget bound protects the model's context window, is measured in tokens, and is derived from the resolved model (offload's spill trigger, compaction, reactive shrink all live on that side). Before this split, the same cap was scaled by the resolved model's input window and counted in characters — both were defects: window-scaling conflated the two roles, and counting a byte-denominated resource in characters drifted up to ~3× for non-ASCII content.
| Field | Type | Default | Description |
|---|---|---|---|
inline_bytes |
int | 10240 |
Ceiling, in bytes, on what file.read / load_skill return inline before truncating. A non-positive or non-numeric value falls back to the default. |
Why 10 KiB: both consumers gained a resume mechanism the same night this default was chosen — file.read via char_offset (#4432), load_skill via deferring to read_file(offset=next_offset) (#4431). A truncated read loses at most one round-trip, not the rest of the content, which is what makes a small default safe rather than arbitrary. Omitting the block keeps this default (behaviour unchanged).
history_resident block¶
Caps Session.history's in-memory footprint (#4387 Phase B ③, applying #4431's resource/budget role split — see the read_cap block above for the full rationale). Same role as read_cap: a resource bound, measured in bytes, model-independent. Deliberately a different axis from the on-demand backward-read window (_HISTORY_HYDRATE_MIN_LINES, measured in lines) — the two answer different questions ("how much stays resident" vs. "how much is read per on-demand fetch") and are kept as separate knobs on purpose, per #4387's own architect review naming the risk of conflating them.
| Field | Type | Default | Description |
|---|---|---|---|
max_bytes |
int | 268435456 (256 MiB) |
Ceiling, in bytes, on Session.history's in-memory footprint. Once exceeded, the oldest resident entries are evicted (never the just-appended newest one) until the cap is met again. A non-positive or non-numeric value falls back to the default. |
Eviction is not information loss: Session.history is a cache, not the source of truth — history.jsonl (append-only, on disk) is, and every entry evicted from memory reloads on demand via the already-shipped backward-hydrate path (TUI scrollback paging, in-conversation search, and WAL rewind visibility all already page older entries back in as needed). This closes an unbounded-growth defect (self.history previously had no cap at all — see #4387) independent of any claim about what fraction of a given memory ceiling history itself accounts for, which this config does not measure or claim to fix.
image block¶
The fixed row height (in terminal rows/cells) every present-rendered inline image is shown at (#4474). Width is derived from this to preserve the image's real aspect ratio — HalfBlockImage (reyn's own image renderable, interfaces/repl/present_renderer.py — no third-party image-rendering dependency, #4474) takes an explicit width/height in cells with no aspect-ratio derivation of its own; passing a fixed height is what makes aspect-ratio-correct rendering possible at all.
| Field | Type | Default | Description |
|---|---|---|---|
row_height_cells |
int | 20 |
Fixed height, in terminal rows, for every inline image present renders. A non-positive or non-numeric value falls back to the default. |
Why this is operator-configurable: the "right" row count is a function of your own terminal height and how much scrollback a photo should occupy — not something reyn can decide for every environment. 20 is a shipped default (tall enough for real photo detail, short enough not to dominate a typical terminal), not a measured "correct" number. Omitting the block keeps this default (behaviour unchanged).
tui block¶
Operator-tunable inline-TUI presentation thresholds (#4542). Today just the status bar's Telemetry segment (model / agent / cost / context%): the percent at which context-window usage escalates from a bare number (16%) to a labelled one (ctx 82%).
| Field | Type | Default | Description |
|---|---|---|---|
context_usage_warn_percent |
int | 80 |
Context-window usage percent at which the status bar labels the figure (ctx NN%) instead of showing it bare. A non-numeric value or one outside 0–100 falls back to the default. |
Why this is operator-configurable: 80 is a shipped default (a plain, unsurprising round number), not a measured "correct" threshold for every operator's own risk tolerance or model/context window — same discipline as image.row_height_cells above. Omitting the block keeps this default (behaviour unchanged).
MCP servers¶
External tool servers reyn can call via the Model Context Protocol. Each entry under mcp.servers: is keyed by a short name (the same name the skill declares in permissions.mcp and emits in mcp ops).
The recommended way to add a server is reyn mcp install <server_id> (see Reference: reyn mcp) — it writes the entry below automatically and handles credentials via ~/.reyn/secrets.env. Manual config is also fully supported.
mcp:
servers:
# stdio: local process, JSON-RPC over stdin/stdout (most official servers)
filesystem:
type: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "."]
env:
FS_LOG_LEVEL: "info"
# stdio with credential from ~/.reyn/secrets.env
github:
type: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: ${GITHUB_PERSONAL_ACCESS_TOKEN}
# http: hosted server, JSON-RPC over Streamable HTTP
internal_tools:
type: streamable-http
url: https://tools.example.internal/mcp
headers:
Authorization: "Bearer ${INTERNAL_TOOLS_TOKEN}"
| Field | Type | Required for | Description |
|---|---|---|---|
type |
string | all | stdio | streamable-http | sse |
command |
string | stdio | Executable to spawn. |
args |
list[string] | stdio (optional) | Argument vector passed to command. |
env |
map[string,string] | stdio (optional) | Extra environment variables for the spawned process. Values support ${VAR} expansion. |
network |
bool | stdio (optional) | Whether the sandboxed server may use the network. Defaults to the same single-source default as sandboxed_exec. Set false to isolate a server that should never reach the network. Operator-owned — the model cannot set it. |
subprocess |
bool | stdio (optional) | Whether the sandboxed server may spawn child processes (fork). Defaults to true — most stdio servers launch via a fork-based launcher (npx → node, uvx → the tool) and must fork to start. Set false to harden a genuinely fork-free server. Operator-owned — the model cannot set it. |
write_paths |
list[string] | stdio (optional) | Filesystem paths the sandboxed server may write, in addition to its working directory (which is always granted). ~ is expanded. Operator-owned — the model cannot set it. A launcher bootstraps into a per-user cache outside the workspace, so reyn grants a default scope for the launchers it recognises (npx/npm → ~/.npm; uvx/uv → ~/.cache/uv + ~/.local/share/uv). Set this when your server's runtime is not one of those, or when you have relocated its cache (e.g. XDG_CACHE_HOME, npm_config_cache) — the defaults are keyed to the standard locations and cannot know yours. Declaring write_paths replaces the built-in defaults, so you can narrow as well as widen. When a write is denied, the error names the denied path and points back at this knob — on BOTH the paths a denial can take: a server that fails to start with Operation not permitted / EPERM (its launcher could not reach its own cache), and a tool call refused a write to a path you passed it (the server started fine; the path is simply outside its scope). Grant the named path here — or, for the tool-call case, pass a path inside the server's working directory instead, which needs no declaration at all. Keep the scope tight: a write grant also re-opens reads for that path. Granting ~ does not defeat the sensitive-read deny-list (~/.ssh, ~/.aws, …) — the deny wins over an overlapping write grant (#2978) — but it needlessly widens the surface, so grant the specific cache directory, never the home directory. |
url |
string | http, sse | Endpoint URL. |
headers |
map[string,string] | http, sse (optional) | Static request headers. Values support ${VAR} expansion. |
call_timeout_seconds |
float | all (optional) | End-to-end bound on every MCP op (list / call / probe), default 120; <= 0 opts out. Covers connecting the server and running the op, so it also bounds a launch — see init_timeout for why that matters. Set it when a server is known to be slow (raise) or known to be quick and you want fail-fast (lower). Per-call, it is passed to the MCP SDK's read_timeout_seconds, overriding the session-level default that timeout sets for type: streamable-http. |
init_timeout |
float | all (optional) | How long to wait for the server to complete the MCP handshake, default 60; 0 disables this bound. Bounds only the handshake, never a tool call — so raising it cannot make a slow tool time out. A server that starts but never speaks (the classic case: command: uvx <pkg>, which downloads from PyPI on first run and stays silent until it finishes) stalls here; on expiry reyn fails with an error naming the likely cause and the remedy. The default sits below call_timeout_seconds' 120 deliberately: both bounds cover the launch, and whichever fires first decides what you get to read — the generic per-op timeout cannot tell you why a launch stalled. So if a genuinely slow server needs more room, raise both — init_timeout alone cannot buy more time than call_timeout_seconds allows. The durable fix for a slow first run is to pre-install the package and point command at the installed executable, which is also what lets the server start offline or behind a proxy. |
elicitation |
string | all (optional) | prompt (default) — a server-initiated structured-input request (elicitation/create) surfaces as a consent prompt; auto_decline — every such request is declined without prompting. See Concepts: MCP § Elicitation. |
elicitation_timeout_seconds |
float | all (optional) | Wall-clock deadline for a human to answer an elicitation prompt. Default 120. An unanswered request past the deadline is cancelled. |
${VAR} in any string value is expanded from os.environ at startup (after ~/.reyn/secrets.env is loaded). Missing variables expand to "" and emit a runtime warning. Use reyn secret set to store values in ~/.reyn/secrets.env — never paste tokens into reyn.yaml directly.
Servers are merged across config sources: ~/.reyn/config.yaml ⊕ reyn.yaml ⊕ reyn.local.yaml. The merge is a shallow union on mcp.servers keys — a per-machine reyn.local.yaml can add or override a single server without re-stating the rest.
The MCP runtime ships in the core install — every session's MCP client is built directly on the official mcp SDK (#4283/#4298/#4299: fastmcp retired from the client path entirely), which is a core dependency, so no extra is required. fastmcp is DROPPED from every reyn-side dependency declaration — core AND every extra (#4302: tests/_support/'s MCP server test-doubles were ported onto mcp's own bundled mcp.server.fastmcp server framework). This is not the whole footprint, though: the bundled rag plugin declares its OWN fastmcp>=3.4,<4 in its own requirements.txt (register-only, never read or installed by reyn's own plugin_install) — a real consumer #4302 found; the upper bound was tightened in #4388 after a loose >=2.0 floor (permitting an untested major version) turned out to be a red herring in a #4371 CI investigation, not the actual cause, but a real latent risk on its own. reyn's dev/CI tooling that runs the plugin's scripts directly (a sandbox gate spawning them as real subprocesses, and a wheel-reachability smoke test's own MCP client) installs that requirements.txt as its own setup step, not by reintroducing fastmcp to any pyproject.toml declaration. mcp is pinned >=2.0,<3.0 (#4412: bumped off the prior >=1.24,<2.0 floor once reyn's own MCP server — src/reyn/mcp/server.py, which had depended on the lowlevel.Server decorator API mcp 2.0 removes — was ported onto 2.0's constructor-kwarg registration shape via the _mcp_server_boundary.build_mcp_server seam, #4368). A now-empty [mcp] extra is retained as a back-compat alias so existing pip install -e ".[mcp]" invocations keep resolving.
See Concepts: MCP for the protocol overview and How-to: use an MCP server for the end-to-end quickstart.
mcp.search_thresholdremoved (#3218 / FP-0066 §7 P1a). TheReynConfig.mcp_search_thresholdfield + itsmcp.search_threshold:parsing were fold-removed as a confirmed no-op: the parsed value was never threaded through tobuild_tools()by either router_loop.py call site, so setting it never took effect.build_tools()'s ownmcp_search_thresholdparameter still exists (default0— always inline; seeMCP_SEARCH_THRESHOLDinsrc/reyn/runtime/router_tools.py), but it is no longer reachable fromreyn.yaml— a caller wanting non-default behavior passes it explicitly in code. Full removal of the underlyingtool_search_toolmechanism is tracked as FP-0033.
skills block¶
Registers SKILL.md-based skills — the same explicit-registration model as mcp.servers (no directory scan; an entry must exist for a skill to be visible).
skills:
entries:
pdf_editing:
path: skills/pdf-editing/SKILL.md # project-root-relative or absolute
description: "Fill, merge, and extract fields from PDF forms"
enabled: true
visibility: menu # menu | on_demand | hidden
| Field | Type | Default | Description |
|---|---|---|---|
path |
string | required | Path to SKILL.md, or its containing directory. |
description |
string | "" |
One-line summary shown in the model-facing ## Skills menu (first line only, 1024-char cap — the Agent Skills specification's maximum for description; #3550). |
enabled |
bool | true |
false removes the entry from the registry entirely. Dominates visibility. |
visibility |
enum | menu |
Which surface names the skill: menu (in the ## Skills system-prompt menu) | on_demand (not in the menu, but returned by the skill_list tool — no standing token cost) | hidden (no model-facing surface at all). |
enabled: false drops the entry before visibility is consulted, so the pair spans 4 states, not 6.
Removed in #2971: auto_invoke (a misnomer — nothing auto-invokes a skill; it only controlled menu rendering, which was then the sole surface naming a skill, so false made the skill unreachable rather than merely unadvertised). A config still carrying it fails at load naming the replacement: auto_invoke: true → visibility: menu; auto_invoke: false → visibility: hidden.
skills.entries merges across ~/.reyn/config.yaml ⊕ reyn.yaml ⊕ reyn.local.yaml ⊕ the dynamic <project>/.reyn/config/skills.yaml (written by the skill_install_local / skill_install_source chat tools), later tiers winning on name collision — the same merge shape as mcp.servers.
See Concepts: Skills for the full registration model, the three-layer exposure model (menu / on-demand read / bundled assets), and the install tools.
pipelines block¶
Registers pipeline DSL files — the same explicit-registration model as skills.entries / mcp.servers (clean break: there is no directory scan; a *.yaml file with no config entry is invisible to every session).
pipelines:
entries:
greetings: # entry KEY = the namespace label
path: pipelines/hello.yaml # project-root-relative or absolute
description: "Minimal greeting pipeline" # optional
enabled: true # optional, default true
| Field | Type | Default | Description |
|---|---|---|---|
path |
string | required | Path to the pipeline's *.yaml DSL file (may hold multiple ----separated pipeline: documents). |
description |
string | "" |
Optional one-line summary; if omitted, the DSL's own description: key is used. |
enabled |
bool | true |
false removes the entry from the registry entirely. |
The entry key is a pure namespace label — it need not equal any declared pipeline: name. Every pipeline in the file registers under the global name {key}.{declared-name} (namespacing is always on). A . is reserved as the namespace separator, so it is forbidden in both an entry key and a declared pipeline: name. A dot-less call/match target resolves to a same-file sibling ({key}.name); a dotted target is a global reference (other_key.name).
pipelines.entries merges across ~/.reyn/config.yaml ⊕ reyn.yaml ⊕ reyn.local.yaml ⊕ the dynamic <project>/.reyn/config/pipelines.yaml (written by the pipeline_install_local / pipeline_install_source chat tools), later tiers winning on name collision — the same merge shape as skills.entries / mcp.servers.
See Concepts: Pipeline registration for the full registration model and the install tools.
presentations block¶
Registers named presentation templates for the present op — the same explicit-registration model as skills.entries / pipelines.entries / mcp.servers. A named template's value is a blueprint: the identical declarative, non-executable component tree an inline present blueprint is (catalog components + JSON-Pointer path bindings). The blueprint lives inline in the entry (no file indirection — a blueprint is small declarative data), and is structurally validated at load time.
Registering a named template is an operator/config action — there is no install tool and no op the model can call to register one. The model authors inline blueprints only; a named template: in a present op is a read-only lookup against this registry. An unknown template name is not an error: the present op falls back through a content-type default viewer to a generic YAML/text view, so the data always reaches the user.
presentations:
entries:
search_results:
blueprint: # required; inline component tree
- component: table
rows: {"$bind": "/results"}
columns:
- {header: Author, path: /author}
- {header: Title, path: /title}
description: "Search results table" # optional
enabled: true # optional, default true
| Field | Type | Default | Description |
|---|---|---|---|
blueprint |
list or object | required | The declarative component tree (same shape + catalog as an inline present blueprint). Validated at load; a malformed blueprint is skipped (logged), or on hot-reload rejects the whole reload (last-good kept). |
description |
string | "" |
Optional one-line summary. |
enabled |
bool | true |
false removes the entry from the registry entirely. |
presentations.entries merges across ~/.reyn/config.yaml ⊕ reyn.yaml ⊕ reyn.local.yaml ⊕ the dynamic <project>/.reyn/config/presentations.yaml, later tiers winning on name collision — the same merge shape as skills.entries / pipelines.entries / mcp.servers. The <project>/.reyn/config/presentations.yaml layer hot-reloads at the turn boundary, so a newly-registered template becomes resolvable on the next turn without a restart.
embedding block¶
RAG embedding model classes and batch settings.
enabled is the one switch and it is off by default — nothing else in this
block turns embedding on. Once it is on, the built-in defaults cover the OpenAI
path, so a fresh install with OPENAI_API_KEY needs no further changes. See
Guide: enable semantic search
for the full opt-in walkthrough, including offline/air-gapped guidance.
Non-OpenAI embeddings behind a LiteLLM proxy. If your embedding class routes through a LiteLLM proxy to a non-OpenAI provider (e.g. an OpenAI-named route like
text-embedding-3-smallthat the proxy maps togemini-embedding-001), the proxy may addencoding_format— which Gemini rejects (UnsupportedParamsError), and the action embedding index build fails →search_actionsis disabled (the retrieval scheme goes dead). The fix is proxy-side: setlitellm_settings:\n drop_params: trueon your LiteLLM proxy so it drops provider-unsupported params. (The client-side flag does not apply on the proxy route — a known litellm behaviour. For a direct non-proxy embedding call, reyn already passesdrop_params=True.) Alternatively use an OpenAI-compatible embedding class, or setembedding.enabled: falseto opt out. reyn surfaces this exact guidance when the index build fails with anUnsupportedParamsError.
embedding:
enabled: false # default (off, opt-in) — provider/cost gate only (#4156)
index:
actions: true # default — the ~10-entry action/mcp/pipeline catalog
repo_knowledge: false # default — the repo-wide knowledge index (#4156)
default_class: standard # class to use when no class is specified
batch_size: 100 # texts per embedding API call (1–2048)
max_concurrent_batches: 1 # parallel batch calls in flight (1–10)
max_retries: 3 # transient-error retries (0–10)
retry_backoff: exponential # exponential | linear
timeout: 60.0 # per-attempt deadline, seconds (<= 0 opts out)
tokenizer: cl100k_base # tiktoken encoding for chunk-size estimation
cost_warn_threshold: 10000 # ask_user gate fires above this estimated chunk count
classes:
light:
model: openai/text-embedding-3-small
standard:
model: openai/text-embedding-3-small
strong:
model: openai/text-embedding-3-large
# custom class with non-default API endpoint
private:
model: openai/text-embedding-3-small
api_base: ${EMBEDDING_API_BASE}
embedding fields¶
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
The provider/cost gate — one meaning only (#4156): may reyn call an embedding provider at all. Default false (opt-in / predictable-safe default — embedding needs a provider + cost). Clean-break replacement for the retired action_retrieval.embedding_class gate (no alias) — the on/off decision lives here; the model class is the separate default_class field below, unaffected. Symmetric model: enabled: false hides everything below regardless of index.* — non-semantic discovery (list_actions, …) and load/invoke verbs (invoke_action, …) are unaffected. When embedding.enabled is false, the embed op pre-flights and returns a decision-enabling status: "blocked" result (naming this key) rather than a silent no-op or an opaque provider error. Prior to #4156, this single flag ALSO decided WHAT gets embedded (bundling the action catalog with an unconditional repo-wide index build, every router-loop turn, with no way to get one without the other) — that decision now lives in index below. |
index.actions |
bool | true |
#4156 — build the ~10-entry action/mcp/pipeline catalog index search_actions depends on, when enabled: true. Negligible TPM contribution (fixed, small population) — kept on by default so the pre-#4156 search_actions experience is unchanged for an operator who never touches this field. |
index.repo_knowledge |
bool | false |
#4156 — build the FP-0066 P3b repo-knowledge index (knowledge_repo_doc + knowledge_repo_src — every reachable .md doc and every other source file in the repo, chunked — measured at ~1,609 chunks / ~4.86M tokens on this repo at #4156's filing, and it scales with the repo, not fixed), when enabled: true. Scheduled on every router-loop turn (sync_repo_ingest_background, a no-op once the index is clean). Default false — this is the workload that burned through an owner's 5M TPM budget in one burst while they only wanted the ~10-entry action catalog (TPM is a tokens-per-minute ceiling; batching cannot reduce total tokens sent, only not indexing can). When this field is false and a turn would have ingested the repo-knowledge index, reyn logs one line per process (repo knowledge indexing is off (embedding.index.repo_knowledge: false, the default) — ...) rather than skipping silently. |
default_class |
string | standard |
Class used when embedding ops don't specify one (used only when enabled: true). Must be a key in classes. |
batch_size |
int | 100 |
Texts per embedding API call. Valid range: 1–2048. |
max_concurrent_batches |
int | 1 |
Parallel batch calls in flight. Valid range: 1–10. Values > 1 are accepted but log a warning until concurrent support lands. |
max_retries |
int | 3 |
Transient-error retries per batch call. Valid range: 0–10. |
retry_backoff |
string | exponential |
Backoff strategy: exponential or linear. |
timeout |
float | 60.0 |
Per-attempt deadline in seconds — how long reyn waits for one embedding attempt before giving up. <= 0 opts out (no bound — the call is then capped only by litellm's own request_timeout, 6000s/attempt, which is indistinguishable from a hang; a warning is logged). The default matches safety.timeout.llm_call_seconds: an embedding call is the same kind of call as a chat LLM call. Applies per attempt, so the worst-case wait is timeout × max_retries plus backoff. This bounds waiting, not spending — see the note below. |
embedding.timeoutdoes not reduce what you are billed for. It caps how long reyn waits; it does not cap how many requests the provider receives. One attempt can put up to 3 HTTP requests on the wire — the OpenAI SDK client retries internally (max_retries=2by default), underneath litellm and underneath this knob — somax_retries: 3can deliver up to 9 requests for a singleembed. Measured against a fast-erroring provider, all 9 are delivered in ~7.6s with the defaulttimeout: 60.0: the bound never engages at all. Loweringtimeoutdoes not lower that count, and reyn's own retry log (attempt 1/3) counts attempts, not requests. reyn's embedding cost report records at most one response perembed, so it is a lower bound on requests delivered. See #3047 for the measurements and the open decisions. |tokenizer| string |cl100k_base| tiktoken encoding used for chunk-size estimation. | |cost_warn_threshold| int |10000| Estimated chunk count above which theask_usergate fires before indexing. Unrelated to the top-levelcost_warnblock — see the note there on why they're separate despite the shared name. |
embedding.classes entries¶
Each key under embedding.classes is a class name. Built-in defaults (light, standard, strong) are pre-loaded; user entries override them and can add new ones.
| Field | Required | Description |
|---|---|---|
model |
yes | LiteLLM model string (e.g. openai/text-embedding-3-small). |
api_base |
no | Override endpoint URL. Supports ${VAR} interpolation. |
extra_body |
no | Provider-specific payload passed through to the API. |
extends |
no | Inherit from another class in the same classes dict and override specific fields. |
Built-in classes (active when classes: is empty or absent):
| Class | Model | Notes |
|---|---|---|
light |
openai/text-embedding-3-small |
Needs OPENAI_API_KEY. |
standard |
openai/text-embedding-3-small |
Needs OPENAI_API_KEY. |
strong |
openai/text-embedding-3-large |
Needs OPENAI_API_KEY. |
All three built-in classes are OpenAI-backed via litellm. There is no in-process local backend (#3128 removed the local-mini / local-e5 sentence-transformers classes and the reyn[local-embed] extras) — an operator who wants a local/offline model adds a custom embedding.classes entry naming a model served behind an operator-run litellm proxy. See Concepts: RAG — Local and offline embedding models for the setup.
chat block¶
Chat fills the context window with raw turns first; compaction fires when the
history exceeds the effective trigger (window-relative, derived from
component_weights against the model's actual context window). Head and tail
zones are token-budgeted, not turn-count gated.
chat:
compaction:
# Budget allocation: integer weights, normalised at runtime.
# Keys: head / body / tail / new_msg / compaction_batch
component_weights:
head: 10
body: 5
tail: 15
new_msg: 10
compaction_batch: 60
section_caps_spec_tokens: 100
use_chars4_estimate: false # true = len(text)//4 (latency opt-out)
body_token_cap: 1500 # hard cap on summary body tokens (post-truncation)
resummarize_passes: 1 # LLM re-compression passes before hard_truncate floor
# Section budget weights within body, normalised at runtime.
section_weights:
topic_arc: 5
decisions: 40
pending: 25
session_user_facts: 10
artifacts_referenced: 35
section_token_caps:
topic_arc: 200
decisions: 400
pending: 400
session_user_facts: 200
artifacts_referenced: 300
chat.compaction fields¶
| Field | Type | Default | Description |
|---|---|---|---|
component_weights |
map[str,int] | {head:10, body:5, tail:15, new_msg:10, compaction_batch:60} |
Integer weights for each prompt component, normalised to main_pool at runtime. Sum is arbitrary; larger values give more token budget to that component. |
section_weights |
map[str,int] | (per-section default) | Integer weights for sub-section allocation within the body budget. Same shape semantics as component_weights. |
section_caps_spec_tokens |
int | 100 |
Static overhead budget for section_token_caps serialisation in the compactor prompt. |
body_token_cap |
int | 1500 |
Hard cap on summary body tokens after post-truncation. |
resummarize_passes |
int | 1 |
Max LLM re-compression passes when a produced topic_arc overshoots its body budget, before the deterministic hard_truncate floor. 0 = skip re-summary (straight to the floor). |
use_chars4_estimate |
bool | false |
When true, use len(text)//4 for token estimation instead of litellm.token_counter (latency opt-out for large deployments). |
chat.compaction.section_token_caps fields¶
Only topic_arc is an enforced cap. All five values are serialized into
the compactor's prompt as size guidance for the LLM, but decisions /
pending / session_user_facts / artifacts_referenced are taken directly
from the LLM's parsed response with no post-processing — nothing truncates
them if the model overshoots. topic_arc alone goes through a 3-tier
deterministic bound after the LLM returns (fit → LLM re-summarize, bounded by
resummarize_passes → a deterministic hard_truncate_summary floor, #1163),
so topic_arc never exceeds its body budget; the other four can.
This is a real, current asymmetry, not a design intentionally scoped that
way — no record motivating topic_arc-only enforcement was found; #1163
replaced topic_arc's previous blind character-cut, and the other four
never had any bound of their own to begin with. It is not being closed:
an oversized decisions/pending/etc. section survives at most one turn.
router_loop_driver.py's pre-frame guard (context_budget_advisor.
maybe_force_compact) recomputes the effective token budget before every
send and forces another compaction pass if the current history still
exceeds it — so an overshoot from one of the four un-enforced sections is
caught and re-compacted at the very next turn, at the cost of that one
turn running with a larger-than-configured section rather than a hard
failure or an unbounded blow-up.
| Field | Default | Description |
|---|---|---|
topic_arc |
200 |
Token target for the topic-arc summary section — the only one of the five that's enforced after the LLM returns (see above). |
decisions |
400 |
Token target for the decisions section, given to the LLM as prompt guidance — not enforced on the returned value. |
pending |
400 |
Token target for the pending-items section, given to the LLM as prompt guidance — not enforced on the returned value. |
session_user_facts |
200 |
Token target for user-facts carried across compactions, given to the LLM as prompt guidance — not enforced on the returned value. |
artifacts_referenced |
300 |
Token target for artifact reference listings, given to the LLM as prompt guidance — not enforced on the returned value. |
Removed keys¶
head_size, tail_size, trigger_total_tokens, and min_compact_batch are
no longer recognised. If present in your reyn.yaml, Reyn emits a
DeprecationWarning at startup and ignores them. Remove these keys — head/tail
sizing is now token-budget via component_weights, and auto-compaction is
window-relative.
audit_events block¶
Audit-log rotation + automatic purge policy for chat-session event files. Skill-run events use one file per run and are not affected by this setting.
Renamed from events: (#4174 T5) — bare "event" is ambiguous in reyn (an
"event" is one of audit-event / WAL-event / hook-event); this block was
always audit-event rotation only.
audit_events:
max_bytes: 10485760 # rotate at 10 MB (default)
max_age_seconds: 86400 # rotate after 1 day (default)
cleanup_period_days: 30 # auto-delete files older than 30 days (default)
max_disk_usage_percent: 10 # auto-delete oldest files past 10% of free space (default)
backend: local # where events are written (default)
| Field | Type | Default | Description |
|---|---|---|---|
max_bytes |
int | 10485760 (10 MB) |
Rotate the active event file when it exceeds this size. 0 = no size-based rotation. |
max_age_seconds |
int | 86400 (1 day) |
Rotate the active event file when it exceeds this age in seconds. 0 = no age-based rotation. |
cleanup_period_days |
int | 30 |
Automatic-purge age axis (#4479) — files whose filename date is older than this many days are deleted. 0 disables this axis. |
max_disk_usage_percent |
float | 10 |
Automatic-purge size axis (#4479) — once the events directory's own total size exceeds this percent of the filesystem's current FREE space, oldest files are deleted until back under. 0 disables this axis. |
backend |
string | local |
#4496 — where audit-events are WRITTEN. local (default) preserves current behavior — events land under .reyn/events exactly as before this field existed. discard (sink-null) writes nothing to disk; subscriber delivery (the TUI/AG-UI forwarders, hooks, OTEL) and the per-emitter audit_seq continuity (#4496 PR-1) are UNCHANGED either way — see Concepts: events for the structural guarantee. discard means reyn events replay / support-bundle / dogfood_trace have nothing to read for this run — in particular, support-bundle is the tool operators use to report bugs, so this trades that away. network is not yet a valid value (an unrecognized string, network included, falls back to local) — its on-failure semantics are an open design question tracked in #4496. |
Setting both max_bytes and max_age_seconds to 0 disables rotation entirely. backend: discard makes both rotation fields moot (nothing is ever written to rotate).
Automatic purge (#4479)¶
Either axis firing deletes files — cleanup_period_days OR max_disk_usage_percent, whichever is touched first (not and: disabling one axis must not silently disable the other). Runs fire-and-forget, off the event loop, at session start and at every rotation; reyn events purge --before <DATE> remains available for a manual, explicit run and shares the same file-selection code.
Both defaults are borrowed conventions, not measurements of reyn's own .reyn/events growth rate (unmeasured as of #4479): 30 days borrows the nearest comparable local-agent CLI's own default (Claude Code's cleanupPeriodDays); 10% borrows systemd-journald's own SystemMaxUse convention. Neither is "the correct" number — both exist as an operator-overridable starting point.
0 means disabled on that axis, not rejected — a deliberate choice, documented here on purpose: Claude Code carries an open report of its own cleanupPeriodDays knob being ambiguous about whether 0 means "delete immediately" or "never delete." reyn's own knobs use 0 = never delete, unambiguously, on both axes.
artifacts block¶
The artifact-ref table fallback's own row cap (#4601). A remote client's — and a local client right after a restart's, #4584's own measured finding — Artifacts pane consults the durable, append-only, persist-tier artifact-ref table when its live conversation view carries nothing. With no cap, this read is unbounded and only ever grows.
| Field | Type | Default | Description |
|---|---|---|---|
remote_fallback_limit |
int | 50 |
Caps the ref-table fallback to the N NEWEST entries (newest-first). A non-positive or non-numeric value falls back to the default. |
remote_fallback_limit is a UX-scale default, not a performance one — a single stat() costs order-microseconds, so even 10,000 rows costs tens of milliseconds; the binding constraint is how many newest-first rows an operator would ever actually scroll through in a list pane, which is a couple of dozen at most. The Artifacts pane's own disclosure text always states "newest N of M" so a truncation is never silent — raise this value if your own usage wants more history visible at once.
voice block¶
⚠️ Currently unavailable. The block still parses (no error if set), but has no consumer — it was built for the Ctrl+R Whisper binding in the old Textual TUI, which was deleted and replaced by the inline CUI (no voice-input binding). Kept for schema completeness only. See concepts: voice.
Voice-input (Whisper) settings, when a consumer exists. Optional — requires pip install 'reyn[voice]' (sounddevice + faster-whisper). The block is lazy-loaded; a missing [voice] extra silently disables the record key.
voice:
enabled: true # set false to disable Ctrl+R even if deps are installed
model: small # tiny | base | small | medium | large-v3
language: ja # ISO 639-1 code; "" or null = auto-detect
device: cpu # cpu | cuda
compute_type: int8 # int8 | float16 | float32
sample_rate: 16000 # Whisper expects 16 kHz mono
cpu_threads: 4 # 0 = OpenMP default
num_workers: 1 # parallel transcription streams
max_duration_s: 300.0 # auto-cancel recordings longer than this (seconds)
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Set false to hard-disable Ctrl+R even when deps are installed. |
model |
string | small |
Whisper model size: tiny / base / small / medium / large-v3. |
language |
string | null | ja |
ISO 639-1 language code. "" or null enables auto-detection (less reliable for short clips). |
device |
string | cpu |
Inference device: cpu or cuda. auto is not supported — it picks the wrong device on some Mac setups. |
compute_type |
string | int8 |
Quantisation: int8 / float16 / float32. |
sample_rate |
int | 16000 |
Sample rate (Hz). Whisper expects 16 kHz mono — do not change. |
cpu_threads |
int | 4 |
CPU threads for faster-whisper. 0 = OpenMP default. Pinning to 4 avoids OpenMP/Python-threading deadlocks on Apple Silicon. |
num_workers |
int | 1 |
Parallel transcription streams. 1 keeps memory + thread usage low. |
max_duration_s |
float | 300.0 |
Auto-cancel recordings longer than this (seconds). Prevents runaway memory growth from unattended recordings. |
multimodal block¶
Controls how Reyn handles binary media (images from web_fetch / read_file / MCP servers) and where multimodal artefacts live on disk.
multimodal:
max_bytes: 5000000 # 5 MB — Anthropic per-image API limit
on_oversize: ask # ask | allow | deny
media_dir: .reyn/media # project-relative dir for image binaries
tool_results_dir: .reyn/tool-results # project-relative dir for tool-result dumps
base_url: null # optional canonical URL prefix for cross-host path_ref
| Field | Type | Default | Description |
|---|---|---|---|
max_bytes |
int | 5000000 (5 MB) |
Decoded-payload byte cap before the on-oversize gate fires. Counts the binary size (len(response.content) / len(file_bytes)), not the base64-encoded shape. |
on_oversize |
string | ask |
What to do when a piece of media exceeds max_bytes: ask (prompt the user via the intervention bus with size + source info; yes loads the media, no drops it), allow (silently accept; use in trusted non-interactive pipelines), deny (silently reject; the op returns status="denied" — use in cost-sensitive contexts). |
media_dir |
string | .reyn/media |
Project-relative directory for image binary storage. Files are flat-named with timestamp + chain-id + tool prefix so ls -la sorts chronologically. Operator-browseable and operator-deleteable. |
tool_results_dir |
string | .reyn/tool-results |
Project-relative directory for text-y tool result dumps. |
base_url |
string | null | null |
Optional canonical URL prefix for cross-host path_ref consumption. When set (e.g. "https://reyn.example.com" from a deployed reyn web), saved artefacts carry a url field pointing at <base_url>/agents/<agent>/tool-results/<artifact> so A2A peers / MCP clients / browsers can fetch the body via the resources router. Unset → no url field minted (same-host fast-path only). |
external_transports block¶
Inbound transport → MCP tool routing for chat. Maps an external transport name (Slack / LINE / Discord / ...) to the MCP tool that delivers replies, plus an args_template describing how router output is shaped into the tool's arguments.
external_transports:
transports:
slack:
mcp_tool: slack__post_message
args_template:
channel: "${TRANSPORT_DEST}"
text: "${ROUTER_REPLY}"
line:
mcp_tool: line__push_message
args_template:
to: "${TRANSPORT_DEST}"
messages:
- type: text
text: "${ROUTER_REPLY}"
| Field | Type | Description |
|---|---|---|
transports.<name>.mcp_tool |
string | Fully-qualified MCP tool name (<server>__<tool>) that delivers the reply. |
transports.<name>.args_template |
map | Shape passed to the MCP tool. ${TRANSPORT_DEST} resolves to the per-message destination identifier (channel / user / room id), ${ROUTER_REPLY} to the router's final text. Other ${VAR} references resolve from os.environ per the standard interpolation rules. |
See src/reyn/runtime/external_routing.py for the per-transport contract and the full set of available template variables.
See also¶
reference/config/permissions.md— full permission grammarreference/config/state-dir.md—.reyn/layout- Concepts: MCP
- Concepts: secret handling —
~/.reyn/secrets.envand${VAR}interpolation - Reference:
reyn secret— managing secrets via CLI - Reference:
reyn mcp— MCP server management CLI