The roster, and how models are chosen
agents/ · routing in scripts/tiers.mjs
Four agents ship in agents/. The conductor (/tyran:run) spawns them; you
can also invoke one directly when you want just that mode of work. For what
each agent assumes before it will work, and for the fourteen skills alongside
them, see what Tyran ships.
| Agent | Namespaced as | Tools | What it is for |
|---|---|---|---|
| scout | tyran:scout | read-only plus Bash | reconnaissance: what the repo does, what the data actually contains, what the docs claim |
| implementer | tyran:implementer | all | one story, from plan to commit or PR on its own branch |
| reviewer | tyran:reviewer | Edit behind a forfeit — a diff it touched can only come back REVISED — never Write | independent quality control on somebody else’s diff |
| verifier | tyran:verifier | read-only plus Bash | mechanical validation: runs the named commands, reports counts and exit codes, never fixes |
| retro | tyran:retro | all | after an initiative closes, improves Tyran itself and nothing else |
Two of these grants are deliberate rather than incidental:
- The reviewer has no
Edit,WriteorNotebookEdit. A reviewer who can fix what they found ends up approving their own patch. This removes the easy path, not every path —Bashcan still write a file — and the agent file says so rather than pretending otherwise. - The scout has
Bashbecause reconnaissance needsgit log,lsandcommand -v. That is the entire justification; the agent is instructed to treat side effects as disqualifying.
Choosing models
Section titled “Choosing models”Model names appear in exactly one file: .tyran/config.yaml. Everything else
— skills, agents, policies, this document — is written in role names. A
model deprecation is then a one-line edit rather than a sweep through every
prompt in the repo, and a sweep through prompts is the kind of change that
reliably gets 90% done.
tiers: cheap: haiku # scout, mechanical sweeps, ledger bookkeeping work: sonnet # DEFAULT: implementation and ordinary review deep: opus # root-cause diagnosis, hard implementation, risky review top: fable # security review, arbitration, final acceptanceResolve a role to a model:
node scripts/tiers.mjs # the whole map, as JSONnode scripts/tiers.mjs --role reviewer # -> one alias on stdoutnode scripts/tiers.mjs --role reviewer --risk high # escalate one stepnode scripts/tiers.mjs --role implementer --effort xhigh # same model, think hardernode scripts/tiers.mjs --role scout --field json # -> {tier, model, effort}The conductor reads the map once at the start of an initiative and passes the
resolved values as the model and effort parameters at spawn.
Two dials, not one
Section titled “Two dials, not one”Model and reasoning effort are separate on purpose, because most of the time you want one without the other. A mechanical sweep on a strong model still does not need deep reasoning; a subtle diagnosis on the middle model very often does. Collapsing them into a single “power” setting is what makes cost modes blunt enough that people stop using them.
| Tier | Default effort |
|---|---|
cheap | low |
work | medium |
deep | high |
top | xhigh |
--risk high shifts both ladders one step; --risk low shifts both down.
A pinned --tier still lets risk move the effort, so “use the cheap model,
but think hard about it” is expressible — otherwise the conductor reaches
for an expensive model just to buy the reasoning.
The conductor is expected to override this
Section titled “The conductor is expected to override this”The table is a starting point, not a prediction of the task in front of it.
The conductor may set --tier or --effort for a single subtask whenever it
can see the default does not fit — that is the intended use, not an escape
hatch. Raise effort for root-cause diagnosis, a failure nobody can reproduce,
an arbitration between two agents who disagree: anything where the first
plausible answer is probably wrong. Lower it for sweeps, bookkeeping, and
re-runs of a recipe that already worked.
Two things keep that from becoming drift. Every deviation is recorded as a
decision event naming the subtask, the default, what was used and why — an
override you cannot justify later is indistinguishable from a habit. And an
override cannot go below a role floor: security-review and
arbitration stay at top/max and high whatever is asked. When a floor
corrects a request, the tool says so on stderr rather than quietly handing
back something other than what was asked for, because a floor that silently
“fixed” the request would teach the conductor that its overrides take effect
when they did not.
The routing table
Section titled “The routing table”Roles down the side, cost profile across the top:
| Role | eco | balanced (default) | full |
|---|---|---|---|
| scout | cheap | cheap | work |
| implementer | work | work | deep |
| reviewer | work | work | deep |
| security review | top | top | top |
| arbitration | top | top | top |
| acceptance | deep | top | top |
| retro | work | work | deep |
| authoring (a skill, an agent, a prompt) | top | top | top |
| bookkeeping | cheap | cheap | cheap |
| conductor (advisory) | deep | top | top |
authoring is separated from retro because retro does two unlike things
with one agent: folding a ledger, which the middle model does fine, and
writing the text every future session will obey. It sits at top with a
max effort floor on a stronger version of the security-review argument — a
bad security verdict costs one merge, while a bad prompt misroutes every run
that reads it, for as long as it ships, and it is the one output nothing
downstream checks. A skill that reads plausibly passes review.
conductor is the one row nothing can enforce: the conductor is your own
session, and no plugin can change a running session’s model. The row records
the choice in the one file where model names may live, and tiers.mjs says so
on stderr when you resolve it.
Read the table as a claim about where model strength changes the outcome. A scout reports what a file says; a stronger model does not make the file say something else. A security reviewer decides whether a hole is real, and a miss there survives every downstream check, because everything downstream trusts it. That asymmetry, not the price list, is what the table encodes.
Implementers and reviewers may raise an operator ask rather than stopping
— node scripts/journal.mjs ask <journal> <init> --question '...' --default '...' [--ticket T-n] — and must NOT set ticket.status on the asked ticket:
the ask already lanes it waiting-operator, and an override on top hides the
question.
The failure this design is actually guarding against
Section titled “The failure this design is actually guarding against”resolveModel throws when a tier has no alias. It would be easy to return
undefined and let the spawn fall back to the session default — and that is
precisely the bad outcome, because routing that silently does nothing is
indistinguishable from routing that works. You would read profile: eco in
your config, watch every agent run on whatever the session happened to be
using, and have no signal at all. A test pins this.
The same reasoning covers a missing config: tiers.mjs falls back to the
plugin’s shipped template so a repo that has not run setup still works, but it
says so loudly on stderr. A silent fallback would let a repo believe it had
adopted a policy it never wrote.
When a tier’s model is out of capacity
Section titled “When a tier’s model is out of capacity”A tier resolves to a model, and a model can be temporarily unavailable — usually because it has hit a rate limit while the account still has budget on everything else. Measured on a real run: the strongest tier hit its limit and the subagents failed rather than finishing on the tier below, which had capacity the whole time.
node scripts/tiers.mjs --role acceptance --unavailable fable --field json# tiers: FELL BACK top -> deep because the top model is unavailable.# {"tier":"deep","model":"opus","effort":"xhigh","floored":false,"fell_from":"top"}It falls DOWN the ladder, never up, and that is the answer to the question this feature was blocked on for a while. Climbing would spend more than the routing table promised, silently, at exactly the moment nobody is watching — and the whole point of the table is that what a run costs is legible before it runs. Down is also the direction the incident needed.
Three things the fall does not do:
- It does not lower the effort. “This task needs deep reasoning” did not
stop being true because a model ran out of capacity, and dropping both dials
at once is a second downgrade nobody asked for.
fell_fromrecords the tier it came from so the substitution is visible in the ledger. - It does not cross a role floor. A security review that ran on the cheapest model is not a security review. The floors in the routing table bound the fall.
- It does not pretend. The substitution is announced on stderr in the same breath as the resolution, saying plainly that this is a weaker model than the table asked for.
When every tier a role may use is unavailable, tiers.mjs exits 2 rather than
returning the bottom of the ladder. That case is not a routing problem — there
is nothing left to substitute — and the thing that already knows how to wait
for capacity is overnight mode.
Re-trying a ticket that already failed
Section titled “Re-trying a ticket that already failed”The escalation rule used to live in the conductor’s memory, which iron rule 7
already names the least reliable store in the system: after a compaction, or
under a second conductor, a ticket that has come back changes-requested twice
is re-spawned at the tier that failed both times, burns the same money and
fails the same way.
node scripts/tiers.mjs --role implementer \ --journal .tyran/state/<init>/journal.jsonl --ticket T-3 --field json# tiers: ESCALATED work -> deep after 2 failed attempt(s) on T-3 (ceiling deep).The journal is what remembers. --journal --ticket counts the reviews on that
ticket whose verdict was not an approval, and climbs one tier per failed
attempt — capped twice over, at two steps and at a ceiling of deep. A ticket
that has failed five times does not need the most expensive model in the table;
it needs a human, and the changes-requested lane is where that is already
visible. A role that already resolves above the ceiling is not dragged down to
it.
The same read picks up any model an error event has recorded as out of
capacity — {class: 'model-unavailable', model: '<alias>'} — and feeds it to
the fallback above. That convention is a convention on purpose: the failure
surfaces inside a subagent’s API call, where no hook can see it, so something
has to write it down before routing can act on it. Detection is still the
operator’s or the conductor’s job; this is what makes the decision durable
once it is made.
State the weakness rather than let someone find it: “did this attempt fail” is
APPROVING_RE, which was written for lane assignment. “Approved with nits”
reads as an approval and escalates nothing; “looks fine” does not and
escalates. A journal that cannot be read routes as a first attempt, loudly —
routing must never depend on a readable journal.
The brake
Section titled “The brake”An operator can halt a running initiative without killing the session:
echo "wrong branch — hold everything" > .tyran/STOPThe conductor and the implementer check scripts/stop-check.mjs before every
spawn, before every merge, and before any long unattended stretch. Exit 1 means
stop and report where you got to. Delete the file and work resumes.
The value is that it needs no session. Someone who sees an overnight run going wrong can stop it from a phone with a one-line commit, and does not have to kill a process and lose the state that would explain what happened. (The idea is borrowed from pro-workflow’s file kill-switch; the code and the semantics here are our own.)
stop-check fails closed. Everything else in this codebase fails open, on
the principle that a broken gate must not block ordinary work — but a brake
that releases itself when damaged is not a brake. An unreadable STOP file, a
STOP that is a directory, an empty STOP: all of them stop.
Overriding an agent in your own repo
Section titled “Overriding an agent in your own repo”Copy the file into .claude/agents/ and edit it. Your copy wins. Note the
trade-off the shipped policy already flags: an override freezes that agent
at the version you copied, so plugin updates stop reaching it. That is why
.claude/agents/** is classified GATED rather than AUTO — it is a decision
worth making on purpose.