Skip to content

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.

AgentNamespaced asToolsWhat it is for
scouttyran:scoutread-only plus Bashreconnaissance: what the repo does, what the data actually contains, what the docs claim
implementertyran:implementerallone story, from plan to commit or PR on its own branch
reviewertyran:reviewerEdit behind a forfeit — a diff it touched can only come back REVISED — never Writeindependent quality control on somebody else’s diff
verifiertyran:verifierread-only plus Bashmechanical validation: runs the named commands, reports counts and exit codes, never fixes
retrotyran:retroallafter an initiative closes, improves Tyran itself and nothing else

Two of these grants are deliberate rather than incidental:

  • The reviewer has no Edit, Write or NotebookEdit. A reviewer who can fix what they found ends up approving their own patch. This removes the easy path, not every path — Bash can still write a file — and the agent file says so rather than pretending otherwise.
  • The scout has Bash because reconnaissance needs git log, ls and command -v. That is the entire justification; the agent is instructed to treat side effects as disqualifying.

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 acceptance

Resolve a role to a model:

Terminal window
node scripts/tiers.mjs # the whole map, as JSON
node scripts/tiers.mjs --role reviewer # -> one alias on stdout
node scripts/tiers.mjs --role reviewer --risk high # escalate one step
node scripts/tiers.mjs --role implementer --effort xhigh # same model, think harder
node 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.

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.

TierDefault effort
cheaplow
workmedium
deephigh
topxhigh

--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.

Roles down the side, cost profile across the top:

Roleecobalanced (default)full
scoutcheapcheapwork
implementerworkworkdeep
reviewerworkworkdeep
security reviewtoptoptop
arbitrationtoptoptop
acceptancedeeptoptop
retroworkworkdeep
authoring (a skill, an agent, a prompt)toptoptop
bookkeepingcheapcheapcheap
conductor (advisory)deeptoptop

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.

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.

Terminal window
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_from records 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.

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.

Terminal window
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.

An operator can halt a running initiative without killing the session:

Terminal window
echo "wrong branch — hold everything" > .tyran/STOP

The 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.

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.