Skip to content

Configuration

written by scripts/scan-repo.mjs · validated by scripts/schema.mjs

All per-repo configuration lives in .tyran/config.yaml — committed, human reviewable, written by /tyran:setup and editable by hand.

Or from the board. npx @jjanczur/tyran board --serve --write puts the cost profile, the deployment autonomy, the four model tiers, the validation commands, the shared zones, the whole boundaries: block and the whole limits: block on a screen, each with a sentence explaining what it does, and writes your change back into this file one line at a time with the comments intact — the autonomy policy too. pricing: and main_writable_paths: stay hand edits. See the Settings tab for what it will and will not touch, and for why loosening a boundary there takes a second press.

# Every inferred field carries its provenance:
# value · source (file/command that proved it) · confidence · needs_confirmation
profile: balanced # eco | balanced | full (cost mode)
autonomy: P1 # P1 branch-only · P2 staging · P3 full
# enforced downward by the policy gate; never INFERRED upward
tiers: # the ONLY place model names appear
cheap: haiku # recon, mechanical sweeps, bookkeeping
work: sonnet # DEFAULT: implementation, ordinary review
deep: opus # root-cause diagnosis, hard implementation, risky review
top: fable # security review, arbitration, acceptance
validation: # detected from package.json / Makefile / CI
- npm run lint
- npm run typecheck
- npm run test
shared_zones: # append-only files, serialized by the conductor
- messages/*.json
main_writable_paths: # OPTIONAL. Out-of-repo paths the MAIN thread may
- '~/.claude/plans/**' # write, on top of the built-ins (the memory store,
# ~/.claude/plans/, and the session scratchpad). A
# `~` expands to home; globs are the usual `*`/`**`.
# Never widens what a SUBAGENT may do — the gate
# enforces the actor split; this only lists paths.
boundaries: # OPTIONAL. How far the policy gate turns its
preset: strict # refusals DOWN. Absent = strict = what Tyran has
outside_repo: refuse # always done. `preset: open` sets all five at once.
credentials: refuse # An explicit key beats the preset, both ways.
path_classes: refuse # Four things NO value here reaches: secret scanning
push: refuse # at commit and push, the enforcement hooks,
prompts: ask # .claude/settings.json, and .tyran/STOP.
limits: # OPTIONAL. Overnight mode (see the Overnight page):
mode: 'off' # off | warn | pause — quoted, bare off is YAML false
pause_at_percent: 97 # five-hour window, [50, 100]
weekly_pause_at_percent: 97
wait_max_hours: 5 # beyond this a pause is LONG: notify + hold
long_wait: hold # hold | resume
resume_margin_minutes: 5
keep_awake: false # true holds the SYSTEM awake while the resume
# watcher waits — never the display, so the screen
# lock is untouched (see the Overnight page)
unattended: # OPTIONAL. What an open question does when nobody
mode: 'off' # is awake. off | on — bare on/off work here too
answer: recommendation # recommendation | default — which recorded value
# an unanswered ask resolves to. Asks raised
# `--blocking`, and asks with neither value
# recorded, always wait for you (../overnight/)
board: # OPTIONAL, and the one block that defaults ON.
autostart: true # start the dashboard with the session, if it is not
# already up. `limits:` off is inert; a board that
# never starts means every projection Tyran writes
# is generated and read by nobody (see the Board page)
port: 4173 # the FIRST port tried — the next 9 are used if it
# is taken, so several repos each get their own
write: true # the Settings tab may edit config.yaml and the
# autonomy policy. false serves the page read-only
open: false # true also launches a browser EVERY session
pricing: # OPTIONAL. The spend ledger (see the Spend page).
rate_card: 'list-2026-08' # free label; it travels with every amount
models: # dollars per MILLION tokens; all four required
<model-id>: # exactly as the ledger's `By model` table spells it
input: 15
cache_write: 18.75
cache_read: 1.5
output: 75
spend: # OPTIONAL. Where cost.mjs looks for THIS repo's
transcript_dirs: # transcripts, when the derived-slug / cwd-probe
- '~/.claude/projects/-Users-me-work-a-different-checkout'
# fallback lands on the wrong session (see the Spend
# page) — a conductor that ran from a working
# directory other than the repo it operates on.
# `--transcripts` on the command line outranks this
# block; both replace the fallback resolution
# entirely rather than adding to it.

Setup also seeds MISTAKES.md at the repository root, create-only. There is no knob for it: deleting the file is the opt-out. What it is for and how an entry graduates is on the self-improvement page, and stated only there.

Profileeveryday work runs ontop reserved foreffort
ecoworksecurity review, arbitrationlowered on sweeps and bookkeeping, never on security
balancedworksecurity, arbitration, acceptancedefault per tier
fulldeepsecurity, arbitration, acceptanceraised one step

Policy is written in role names, never model names — a model deprecation becomes a one-line change in tiers:, not a rewrite. A test asserts that no agent or skill file contains a model alias, so the rule is enforced rather than merely intended.

The full routing table, the role floors, and how the conductor overrides either dial for a single subtask are in the roster.

The conductor has a row of its own (deep under eco, top otherwise) and it is advisory: no plugin can change a running session’s model, so the row records what your config says the coordinator should be running and tiers.mjs prints that caveat on stderr. Everything else in the table is applied.

You do not have to write this block. Tyran ships the published list prices — the card is labelled list-2026-08 — so the spend ledger reports money on a fresh install with no configuration at all. pricing: is how you override them, and it is operator-written, never scanner-inferred, so it carries no provenance wrapper, exactly like limits:.

This reverses an earlier decision that absent pricing must show no money, taken on the ground that “Tyran does not know what anyone pays”. That conflated two quantities. What a subscriber pays at the margin is zero and Tyran indeed cannot know it; what these tokens would list-price at on the API is published and fixed. The ledger reports the second and labels it as such — the tile reads cost through the API, and rate_card travels with every amount. Read that label as load-bearing: it is the condition the reversal rests on.

keyrequiredaccepted
rate_cardnoa non-empty label, free text ('enterprise-q3')
modelsnoa mapping of model id to a rate block
models.<id>.input · cache_write · cache_read · outputall foura finite number ≥ 0, in dollars per million tokens
models.<id>.cache_write_1hnoas above; defaults to the cache_write rate

The model id is the string the platform reports, which is what the ledger’s By model table prints — copy it from there rather than from a price list, so the key you write is the key the transcripts will match.

Overriding is per model, not all or nothing. A negotiated rate for one model keeps the shipped numbers for every other, rather than costing you every other amount. A card that mixes the two says so: the label becomes list-2026-08+config, because an amount computed partly from list prices must not claim to come from someone’s private card.

The four base rate keys are required, and that is the point of the block. A table carrying three of them would price the fourth at zero, silently; cache reads alone were measured at roughly three quarters of a real session’s cost, so the omission would not be a rounding error, it would be the bill. A partial rate card is a wrong number, not a partial one. The validator therefore rejects a model missing any of the four, and pricingOf drops a model whose rates fail that shape rather than pricing it — enforcing a value the schema rejects is how a bad config becomes a confident wrong answer. A dropped model is not silent: it appears in the ledger’s unpriced list, counted in tokens and absent from every amount.

cache_write_1h is the one exception, and it is optional for compatibility rather than because it matters less: cards written before the 1-hour rate existed carry four keys, and failing them on upgrade would unprice a working ledger. Absent, 1-hour writes are billed at the 5-minute rate — which under-reports, since a 1-hour write costs 2x base input against the 5-minute write’s 1.25x. A long agent run caches for an hour, so this is not a corner: measured on 1.8 B tokens of real transcripts, the 1-hour line was $175 against the 5-minute line’s $70.

What the shipped card does NOT model, each of which makes the real bill higher than reported: fast mode (2x on Opus 5 / 4.8), US-pinned inference (1.1x), and server-side tool charges such as web search at $10 per 1,000 searches. None of them is visible in a transcript.

rate_card is a label, not a lookup — nothing fetches anything. It exists so that every amount can say which card produced it, because two people quoting different cards get different money from one set of tokens.

spend: is the other optional, operator-written block the spend ledger reads — no provenance wrapper, exactly like pricing: and limits:. cost.mjs normally locates a repo’s transcripts itself: first by the directory name the platform derives from the repo path, then, if that is absent, by opening every project directory and matching on the cwd its records carry. Both assume the conductor session ran from the repo it operates on, and when it did not — Claude Code Desktop opened in a sibling folder, working the repo through absolute paths and worktrees — neither heuristic can find it: the transcript lives under a project directory named after the conductor’s own working directory, not the repo’s.

keyrequiredaccepted
transcript_dirsnoa list of non-empty directory paths, each the per-project directory holding <session>.jsonl and <session>/subagents/

A leading ~ expands to the home directory. A path without ~ or a leading / resolves against the process’s current working directory — the shell cost.mjs (or board.mjs --serve) happens to have been launched from, not .tyran/’s location and not the repo root. Write an absolute path or a ~ path; a bare relative entry works only by accident of where the command was run, which is the exact kind of assumption this block exists to replace.

Given a non-empty list, cost.mjs scans the union of those directories instead of running either heuristic; a directory that does not exist is reported in the report’s transcript_dirs_missing rather than silently skipped. --transcripts <dir> on the command line is the same override and outranks this block when both are given. Full account of the failure this exists for, and what the board shows when it fires, is in the spend ledger.

ClassTyran mayAlways gated
P1commit + push to a branch, open PRsany merge
P2additionally: merge/release to stagingproduction
P3additionally: merge to main with auto-deployirreversible or user-visible operations, feature-flag flips

The class is detected from your repo (branch protection, merge history, staging presence) and confirmed by you once. scan-repo never infers P3: no arrangement of files is evidence that a person meant to let an agent deploy to production.

autonomy: above decides how far finished work travels. boundaries: decides what agents may touch while they make it, and it is the one place the policy gate can be turned down at all.

Every value ships at its strict setting, which is exactly what Tyran did before the block existed. Nothing changes by having it in your file — it is written out in full so the Settings tab can edit it, because that screen patches keys that already exist and deliberately will not invent them.

keystrictloosewhat the loose value gives up
outside_reporefuseallowa path outside the repository stops being refused, for every agent — not only the main thread, which is the narrower thing main_writable_paths: already does
credentialsrefuseallow.env, private keys, ~/.ssh, ~/.aws and their relatives can be read into the conversation, on Read, Grep and in a shell command
path_classesrefuseallowthe GATED and KERNEL rules in your own policy stop being enforced for writes
pushrefuseallowthe deployment class is not checked at all, including the pushes nothing can undo — --mirror, a force push to the default branch, a deleted remote branch
promptsaskskipClaude Code stops prompting for calls no gate objects to. This is the one that people mean by “skip permissions”

preset: sets all five at once. strict is the default; open is the loose column, top to bottom:

boundaries:
preset: open

An explicit key beats the preset in both directions, so a repo that wants room to work but not its own keys read is four lines:

boundaries:
preset: open
credentials: refuse

Named as a list rather than implied, because “everything” needs a floor and this block lives in a file agents can write:

  1. Secret scanning. gitleaks runs from the secrets gate, a different hook that never reads this block, and a deny from any hook beats an allow from another. A commit or push carrying a key is refused under preset: open with prompts: skip, and a test pins exactly that.
  2. hooks/** and .tyran/policies/**. The enforcement scripts and the policy that classifies them.
  3. .claude/settings.json, .claude/settings.local.json and .tyran/STOP. The hook registry and your brake. A flag the loop can use to clear its own stop signal is not a flag.
  4. The control-character guard, the evidence gate and the usage gate, none of which read this block either.

.tyran/config.yaml is class AUTO in the shipped policy, so an agent can widen these itself — the same trade autonomy: already makes, and the reason the four exceptions above are mechanical rather than a matter of anyone’s good behaviour. What holds regardless: nothing in this block lets a session switch a gate off, edit the hook registry, clear .tyran/STOP, or publish a secret.

A value the validator rejects resolves to strict, never to the loose side. credentials: yes is not allow — guessing that it was would open the secret rule on a typo. node scripts/doctor.mjs --state reports boundaries-relaxed whenever anything is loose, because relaxing a boundary removes refusals, and nothing else in a session will ever mention it again.

Self-improvement boundaries (.tyran/policies/autonomy.yaml)

Section titled “Self-improvement boundaries (.tyran/policies/autonomy.yaml)”

Three artifact classes, enforced by a PreToolUse hook on file paths:

  • AUTO — knowledge facts, rule tweaks, .tyran/config.yaml, agent overrides (since 0.1.44), repo-specific skills (with a passing activation test): the retro agent commits these itself.
  • GATED — new/changed hooks, CLAUDE.md, budget raises, deletion of safety rules: proposed, you approve.
  • KERNEL — the enforcement hooks themselves, the rollback mechanism, and this very list: never touched autonomously.

You can tighten (or loosen) the classification per repo by editing the file.

The schemas are executable — every file family has a validator that CI and /tyran:doctor run:

Terminal window
node scripts/schema.mjs validate config .tyran/config.yaml
node scripts/schema.mjs validate knowledge .tyran/knowledge/*.yaml
node scripts/schema.mjs validate policy .tyran/policies/autonomy.yaml

Exit 0 means valid; exit 1 prints one finding per line with the exact path (entries[2].confidence: must be a number in [0, 1]).

The policy line is one you run, in your own terminal. Inside a Claude Code session the policy gate refuses any shell command that names a path under .tyran/policies/**, and it makes no exception for a validator — that is the boundary protecting itself. An agent checks the same file with node scripts/doctor.mjs --state --dir .tyran, which validates everything in .tyran/ and names only the directory.

Tyran parses a deliberately small YAML subset (zero dependencies) and rejects the rest loudly rather than risk a file meaning something different here than under a full YAML engine. Supported: mappings, block sequences, inline flow sequences of scalars, quoted strings, comments, ---. Rejected with a line number: anchors/aliases, tags (!, !!) — in keys as well as values — block scalars (|, >), flow mappings ({}), nested flow sequences, tabs for indentation, duplicate keys, and multiple documents in one file.

FieldRequiredMeaning
idyesstable identifier (K-1)
kindyesfact · convention · gotcha · command · decision
textyesthe rule, in one sentence
confidenceyes0–1; later retros raise or lower it
provenance[]yes{source, reference} — where it was learned
used / helpful / outdated_reportsnocounters; knowledge.mjs audit names the entries they have written off
applies_to[]nopath globs this entry is scoped to
supersedesnoid, or list of ids, this entry replaces; each one stops reaching briefs while staying on disk with its counters

provenance[] entries need both source (where it was learned) and reference (which run, file or commit proves it).

Knowledge is read back by scripts/knowledge.mjs brief, which selects the entries whose applies_to globs intersect the paths a story is about to touch, ranks them by confidence, cuts to a character budget, and prints a block the conductor pastes verbatim into a handoff:

Terminal window
node scripts/knowledge.mjs brief src/lib/feed/pagination.ts 'src/app/**' \
--kinds gotcha,convention --budget 3000

The budget is why entry size matters: doctor --state warns (knowledge-entry-oversized) when a single entry’s text exceeds 4000 characters, because one document-sized entry crowds out every other entry a brief would have carried. Keep an entry a fact; put the essay in docs/ and the pointer here.

supersedes is how the store stops growing without anything being destroyed. Merging two entries means writing a third in a new file, naming both in its supersedes, and leaving the originals alone — they drop out of every brief while keeping their counters, and deleting the new file brings them back. An entry may not supersede itself, and a supersedes naming an id that is not in the store is reported by knowledge.mjs audit, because that retirement silently did not happen. See self-improvement.

Lease files (state/*/locks/) are runtime, not history: /tyran:setup seeds .tyran/.gitignore to keep them out of git, and doctor --state warns (lease-file-tracked) if any are committed anyway.

policies/autonomy.yaml needs an explicit default: class for paths no rule matches (GATED is the strict answer; the shipped template says AUTO since 0.1.44), and the most specific matching rule wins — measured by glob length, ties resolved toward the stricter class. ** spans path separators, * does not.