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.
.tyran/config.yaml
Section titled “.tyran/config.yaml”# 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.
Cost profiles
Section titled “Cost profiles”| Profile | everyday work runs on | top reserved for | effort |
|---|---|---|---|
eco | work | security review, arbitration | lowered on sweeps and bookkeeping, never on security |
balanced | work | security, arbitration, acceptance | default per tier |
full | deep | security, arbitration, acceptance | raised 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.
The rate card
Section titled “The rate card”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.
| key | required | accepted |
|---|---|---|
rate_card | no | a non-empty label, free text ('enterprise-q3') |
models | no | a mapping of model id to a rate block |
models.<id>.input · cache_write · cache_read · output | all four | a finite number ≥ 0, in dollars per million tokens |
models.<id>.cache_write_1h | no | as 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.
Explicit transcript directories
Section titled “Explicit transcript directories”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.
| key | required | accepted |
|---|---|---|
transcript_dirs | no | a 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.
Autonomy classes
Section titled “Autonomy classes”| Class | Tyran may | Always gated |
|---|---|---|
P1 | commit + push to a branch, open PRs | any merge |
P2 | additionally: merge/release to staging | production |
P3 | additionally: merge to main with auto-deploy | irreversible 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.
Boundaries — giving agents more room
Section titled “Boundaries — giving agents more room”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.
| key | strict | loose | what the loose value gives up |
|---|---|---|---|
outside_repo | refuse | allow | a 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 |
credentials | refuse | allow | .env, private keys, ~/.ssh, ~/.aws and their relatives can be read into the conversation, on Read, Grep and in a shell command |
path_classes | refuse | allow | the GATED and KERNEL rules in your own policy stop being enforced for writes |
push | refuse | allow | the 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 |
prompts | ask | skip | Claude 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: openAn 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: refuseWhat open never reaches
Section titled “What open never reaches”Named as a list rather than implied, because “everything” needs a floor and this block lives in a file agents can write:
- Secret scanning.
gitleaksruns from the secrets gate, a different hook that never reads this block, and adenyfrom any hook beats anallowfrom another. A commit or push carrying a key is refused underpreset: openwithprompts: skip, and a test pins exactly that. hooks/**and.tyran/policies/**. The enforcement scripts and the policy that classifies them..claude/settings.json,.claude/settings.local.jsonand.tyran/STOP. The hook registry and your brake. A flag the loop can use to clear its own stop signal is not a flag.- The control-character guard, the evidence gate and the usage gate, none of which read this block either.
The cost, stated rather than implied
Section titled “The cost, stated rather than implied”.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.
Validating your files
Section titled “Validating your files”The schemas are executable — every file family has a validator that CI and
/tyran:doctor run:
node scripts/schema.mjs validate config .tyran/config.yamlnode scripts/schema.mjs validate knowledge .tyran/knowledge/*.yamlnode scripts/schema.mjs validate policy .tyran/policies/autonomy.yamlExit 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.
YAML subset
Section titled “YAML subset”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.
Knowledge entry schema
Section titled “Knowledge entry schema”| Field | Required | Meaning |
|---|---|---|
id | yes | stable identifier (K-1) |
kind | yes | fact · convention · gotcha · command · decision |
text | yes | the rule, in one sentence |
confidence | yes | 0–1; later retros raise or lower it |
provenance[] | yes | {source, reference} — where it was learned |
used / helpful / outdated_reports | no | counters; knowledge.mjs audit names the entries they have written off |
applies_to[] | no | path globs this entry is scoped to |
supersedes | no | id, 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:
node scripts/knowledge.mjs brief src/lib/feed/pagination.ts 'src/app/**' \ --kinds gotcha,convention --budget 3000The 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.
Policy precedence
Section titled “Policy precedence”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.