Skip to content

Model policies and task classes

Every field of ModelPolicy, the real scoring terms, the three postures and the thirteen task classes.

23 min readUpdated 13 Sept 2026Reviewed 12 Sept 2026Published 12 Sept 2026/docs/reference/model-policies

A model policy is a named set of decisions about which model a role should get: a tier it normally runs at, a floor and a ceiling it may never leave, per-task-class overrides, an escalation threshold, and an output cap. This page is the field reference for it and for the vocabulary it is written in.

Two different objects are in play, and keeping them apart explains most confusion about routing. A policy belongs to a role on the org chart: it says how much model this employee's work deserves, and it is written in tiers rather than model ids so it survives a catalogue that changes every few weeks. A posture belongs to a floor — cheap, balanced or quality — and it is the operator's hand on the whole building: the installation sets a default and each floor may override it. A policy is evaluated once per turn, by routeModel, against the task class and a complexity estimate; the posture is one of its inputs. The overview of why the mechanism works this way is on Model routing; what follows is the exhaustive version.

The fields

Nine fields, three of them required. The type is ModelPolicy in the core package, and every shipped role gets its policy from one helper that fills in minTier: 'small' and maxTier: 'max' before anything else is applied — which is why those two look like universal constants and are not.

FieldTypeRequiredShipped defaultWhat it changes
defaultTierModelTieryesper roleThe tier this role normally runs at, and the starting point for the target tier. One of nano, small, standard, strong, max.
byTaskClassPartial<Record<TaskClass, ModelTier>>noevery shipped role sets onePer-task-class overrides, consulted before defaultTier. A class that is absent falls through to defaultTier. A key that is not a real task class is never looked up and therefore does nothing at all.
minTierModelTieryessmallThe hard floor. Also the tier cheap aims at. researcher is the one shipped role that lowers it, to nano.
maxTierModelTieryesmaxThe hard ceiling. The clamp is the last word on the target tier, and it applies to every path including escalation and a posture change.
escalateAtComplexitynumber (0..1)noabsent on ui-designer and researcherThe complexity estimate at which the turn is escalated. Compared with >=, so hitting the threshold exactly escalates. Only meaningful together with escalateTo.
escalateToModelTiernoabsent on the same two rolesThe tier to escalate to. It replaces the target rather than moving it up one, so it can in principle lower a tier — and it is then still clamped into the policy's own bounds like everything else.
maxOutputTokensnumberno4096; 3072 on ui-designer and platform-engineerA per-response output cap, passed to the provider with the request. It is a cap on the answer, not on the prompt, and it is not part of model selection at all.
pinbooleannonever set by a shipped roleWhen true: honour byTaskClass exactly and never escalate, and ignore the posture entirely. It does not name a model.
preferredModelIdstringnonever set by a shipped rolePin the role to one concrete model by id. Honoured inside minTier..maxTier rather than above them; a pin the bounds exclude is reported in the routing reason and normal selection proceeds.

What each field does not do

Four of these are routinely misread, and the mistakes are all the same shape — assuming a field is more powerful than it is.

  • minTier and maxTier constrain the target tier, not the outcome. They are applied to the target before the walk and the score run, and they are the bounds a pin is checked against. What they are not is a filter on candidates: a model outside the band can be scored, and — with a plugin rule naming that tier — can win. That is the deliberate hole in the band, and What a plugin can change is where it is explained.
  • pin is not a model pin. The name invites the mistake, and the type comment is the authority: it means "honour byTaskClass exactly and never escalate". Setting it skips the posture change, the budget-pressure downgrade and the escalation step in one move. Pinning a model is preferredModelId.
  • escalateTo is not clamped to being above the policy tier. It is a straight assignment followed by the bounds clamp, so an escalateTo below defaultTier lowers the tier when complexity is high. Nothing shipped does that, but nothing prevents it either.
  • maxOutputTokens does not bound the turn. It bounds one model response. A turn is a loop of up to eight model round trips, so a turn can produce far more than its cap in total — and a response that stops at the cap produces the partial-failure sentence The model hit its output limit mid-turn; the reported work product is incomplete. while the turn still finishes with status done and emits no error event. A cap that is hit is reported inside the transcript, not as a failed turn.

The two output-cap fallbacks are different

Because the cap is optional, what happens when it is absent depends on the adapter. The OpenAI-compatible adapter simply omits max_tokens from the request body, so the vendor's own default applies — which is usually whatever the model was trained to stop at rather than a number this office chose. The Anthropic adapter falls back to the catalogue's maxOutputTokens for the chosen model, because the Messages API requires max_tokens and has no default to inherit. So an unset cap is a different thing on each side of the provider list, and the two catalogue values differ too: shipped entries range from 4,096 to 100,000. If an output cap matters to you, set it in the policy rather than relying on either fallback.

The scoring function, term by term

Once the target tier is known, every candidate model is scored and the highest score wins. The weights sum to 1 so a score reads as a 0..1 figure, and the whole expression is:

score = 0.45·fitness + 0.20·quality + 0.35·tierAffinity
        − costPressure·relativeCost − reliabilityPenalty + hintBonus

Capability filtering happens before any of this and is not tier-based. The candidate pool is built by removing excluded model ids, then models that cannot call tools when the turn needs tools, then models without vision when vision is required, then models whose context window is below the requirement. Only the survivors are scored. A model is never kept because its tier is convenient, and never dropped because its tier is inconvenient.

fitness — 0.45, the headline term

How good this model is at this turn's task class, 0..1. It comes from the blended quality opinions attached to the catalogue entry: a per-class fitness where a source expressed one, and otherwise the model's overall quality as a weaker claim about the class. When nothing has an opinion about a model, it is given the mean of what the rest of the pool scored — the population prior, not the tier's baseline, deliberately, so that tier is not counted twice and a large unremarkable model cannot outrank a small good one merely by being large.

This term is why the old "filter by tier band, then pick the cheapest" description was wrong. It is worth 0.45 against tier affinity's 0.35, so a model that fits the work materially better can win from a tier away — and the model it beats is often the one the policy's own target tier pointed at. What it cannot do is rescue a candidate that failed capability filtering, or lift a model whose tier does not appear on the walk at all: that model's affinity is 0, which costs it the whole 0.35 term before fitness is counted.

quality — 0.20, overall capability

How good the model is in general, independent of the task class, blended from the same three sources: the curated catalogue that ships with the server, what this office learned from its own finished turns, and pooled public benchmarks when a key authorises them. It matters when the class is a perfect match for several models and something still has to separate them, which is common — a catalogue has more models per tier than it has distinct strengths.

The source is named in the routing reason as a provenance clause such as quality from curated+learned, because "0.88 because an operator typed it" and "0.88 because a benchmark aggregator said so" should not read the same to somebody asking why a turn cost what it did.

tierAffinity — 0.35, and how the walk works

The walk is an ordered list of tiers, and a model's affinity is a function of its position in that list: 1 − 0.15 × position, floored at zero. Position 0 scores 1.00, position 1 scores 0.85, position 2 scores 0.70, and a tier that is not on the walk at all scores 0. The walk is built in four parts, and the order is the interesting part:

  1. Tiers a plugin rule asked for, in the order the rules asked for them. This is the only way a plugin preference can change the tier the router settles on.
  2. The policy's target tier — after byTaskClass, the posture and escalation have all had their say, and after the minTier/maxTier clamp.
  3. Every tier above the target, weakest first. standard is followed by strong and then max.
  4. Every tier below the target, strongest first. standard is followed by small and then nano.

Steps 3 and 4 are why the walk is not a simple distance: the engine reaches for a bigger model before settling for a smaller one. That asymmetry is the policy's own preference expressed as an order, and the affinity constant only says how much each further step is worth. A consequence worth internalising: one step down the walk costs 0.0525 of score (0.35 × 0.15), which is small enough for a well-fitted model to overcome and large enough that four or five steps cannot be overcome by fitness alone.

costPressure · relativeCost — how much price is allowed to matter

Cost enters as a product, and both factors are worth understanding separately. relativeCost is a 0..1 figure computed within the candidate pool: each model's blended rate — the average of its per-million input and output prices, on a per-1k basis — is passed through log1p, then min-max normalised so the cheapest model in the pool is 0 and the most expensive is 1. The logarithm is there because the spread between a free local model and a frontier one is enormous, and a linear scale would collapse everything except the cheapest into one indistinguishable bucket. It also means relativeCost is relative: the same model can score 1.0 in a pool of cheap models and 0.2 in a pool of expensive ones.

costPressure is set by posture — cheap 0.30, balanced 0.12, quality 0.03 — with two special cases that override it. It becomes cheap's 0.30 whenever the run has less than five cents left, whatever the posture says, so a nearly-broke run prices itself down without anyone changing a setting. And it becomes exactly zero when no model in the pool is rated: with nothing known about what money buys, price must not be allowed to pull the turn off the tier the policy asked for. That single rule is what makes a keyless, network-less checkout route exactly as it did before quality scores existed — the policy's tier wins and the cheapest model in it is chosen.

reliabilityPenalty — a demotion, never an exclusion

Observed upstream uptime, when it is known, costs a model up to 0.30: the penalty is 0.30 × (1 − uptime), so an endpoint at 0 % loses the full amount, one at 50 % loses 0.15 and one at 99 % loses 0.003, which is noise. Three properties matter. It is only applied when uptime is known — an unmeasured model is not penalised for being unmeasured, or every provider outside OpenRouter would lose to every provider inside it. It is never an exclusion: 0.30 is comparable to a tier step and a half, which can be overcome by a better fit, and it deliberately does not remove a pinned role's only option on the strength of a rolling 30-minute reading. And it never blocks a turn: lookups are on demand, answered from cache when fresh and scheduled in the background otherwise, so the first turn on a new model routes exactly as it would have before health tracking existed.

hintBonus — plugin preferences, as additive score

A plugin routing rule contributes +0.08 for a preferred model id, +0.04 for a preferred provider and −0.12 for an avoided model. Three things about them are not obvious. They are scoped to one tier — the tier the rule names, or the policy's target tier when it names none — so a bonus cannot move a decision across tiers by itself; a rule that wants to change the tier has to say so with tier, which enters the walk instead. They are additive across rules, so two rules naming the same model add up. And they cannot add a candidate: they are applied to models that already survived capability filtering, so no hint can make the router pick something that cannot call tools or lacks the context window.

Ties, and the ordering that is not a score

Equal scores fall through to blended cost and then to the model id, in that order. That is not a detail — it is the backward-compatibility guarantee. When nothing is rated, every score is equal, and this tiebreak reproduces the original behaviour exactly: cheapest first, then by id. The same ordering decides the fallback list, which is why a fallback is the next-best model for this work rather than merely the next-cheapest one.

The postures, and what each one actually changes

All three postures act on one number — the target tier — and then get out of the way. Each is applied after byTaskClass has chosen the origin tier, and before escalation:

PostureWhat it does to the targetCost pressureWhen it does nothing
cheapReplaces the target with minTier. Also forced for any turn in a run with under five cents left.0.30When the origin already is minTier. The reason clause is only emitted when the tier actually moved.
balanced (the default)Leaves the target alone: policy.byTaskClass[taskClass] ?? policy.defaultTier.0.12Never — it is the absence of an intervention, which is why it is the default.
qualityMoves one tier up from the origin, clamped at max by the step itself and then clamped into the policy bounds.0.03On a role already at max, and on a role whose maxTier is its origin tier.

Two consequences follow that the names hide.

"Quality" is not "pick the most expensive model", and not "pick the strongest one" either. It moves the target one tier and then runs the same scoring as everything else, so within the new tier the decision is still fitness, quality, cost and reliability. It does not disable cost awareness — pressure drops to 0.03, which is small but not zero — and it does not remove the policy's ceiling, so a role capped at strong never sees max no matter what the posture says. A "quality" run on a role already at its ceiling is a no-op that looks like a setting. And because the origin tier lands at walk position 1 afterwards (affinity 0.85), a genuinely better-fitting model at the original tier can still win the turn — the posture tilts the decision, it does not dictate it.

A posture is not the policy. It cannot raise minTier, lower maxTier, change byTaskClass, disable escalation, or move a pin. The installation's posture is a default and a floor may override it; the engine prefers the floor's value whenever the floor has one. And when a policy sets pin: true, the posture branch is skipped entirely — no downgrade, no upgrade, no escalation — which makes a pinned role the one place where the operator's building-wide dial has no effect.

Task classes

A task class is the vocabulary that says what kind of thinking a turn needs. It is the unit the router prices work in, and it is shared: the same class selects skills for the turn, scopes plugin routing rules, and names a role's byTaskClass override. Thirteen exist, in the core package's TASK_CLASSES:

ClassWhat it is forReachable from
intakeParsing a raw brief into a structured objective.The intake stage
routingMeta: deciding who should own a piece of work.nothing in the engine
summarizeCondensing a transcript or a thread.The report stage, and every direct conversation
planningDecomposing an objective into workstreams.The plan stage
researchGathering external or documentary evidence.The research stage
debateArguing a position against another employee.The debate stage
workshopConverging a debate into a decision.The workshop stage
designVisual or product design.The design stage
architectureTechnical or system design.The architect stage
codingWriting or modifying code.The build stage
reviewCritiquing code or a plan.The review stage
testingWriting and running tests.The test stage
opsBuild, deploy and infrastructure work.The integrate stage

The mapping is a fixed function of the stage's kind — build is always coding, report is always summarize, and so on — so nothing about the words in a brief can change it. The class is computed once per turn and then flows into three places: the router, per-turn skill selection, and the complexity estimate.

Three honest notes on that table. routing is declared and never produced. No stage maps to it, the only non-stage route prices itself as summarize, and nothing else in the engine passes it. It exists in the vocabulary, at least one shipped skill claims it in its taskClasses, and a plugin rule could name it — but a turn in this build will not arrive carrying it. An unknown class is silent. byTaskClass is an object lookup, so a key that is not one of the thirteen is simply never matched: the shipped frontend developer policies carry debugging: 'strong' in their override map, and because debugging is not a task class, that entry has never applied to anything. The same failure mode applies to a plugin rule — a rule with a misspelled taskClass keeps working, but for no class at all. The default branch is unreachable from a stage. The mapping function ends with a default that returns summarize, and since the twelve stage kinds are a closed union and all twelve are handled, that default exists for the type checker rather than for a real turn.

An unclassified turn

The one route that is not a stage is a direct conversation with an employee, and it is deliberately stripped rather than classified. Its policy is a copy of the role's with byTaskClass deleted, and it is routed as summarize at a complexity of 0.3. Deleting the overrides is the point: a chat turn must not be priced by whatever the role happens to think about coding, because there is no code in it. The class still has consequences — skill selection sees summarize, and difficulty 0.3 is below every shipped escalation threshold, so a conversation never escalates a role into a frontier model. The prompt tells the model it has no tools in a direct conversation, which is why the answer is a sentence rather than a claim to have read something.

What a plugin can change

A plugin influences routing through contributes.routingRules, an array of rules with id, description and five optional fields. The field-by-field manifest contract is on Plugin manifest; what matters here is what each one does to the mechanism above, including the two places the code's own comments used to disagree with the code.

Rule fieldWhat it does to the routerThe catch
taskClassScopes the whole rule to one class.Optional. A rule with no taskClass applies to every class, which is a wide blast radius for what may have been meant as a local preference.
tierPuts that tier at the front of the walk, ahead of the policy's target. This is the only mechanism by which a hint can change the tier the router settles on.The hinted tier is not clamped to the policy band. A rule naming max on a role capped at standard gives max models walk position 0 and full affinity — the policy's ceiling constrains the target, not the walk. A rule naming a tier is therefore the most powerful and least obvious field in the manifest.
preferModelIds+0.08 to each named model, and first place in the fallback ordering.Applied to models in the rule's tier only. A preferred model in another tier gets nothing from this field.
preferProviderIds+0.04 to every model from that provider, and earlier in the fallback ordering.Same tier scoping. +0.04 is smaller than the fitness term's sensitivity to a single good match, so it orders candidates rather than deciding between them.
avoidModelIds−0.12 to each named model, and last place in the fallback ordering.A penalty, not an exclusion — the type comment says "should never be chosen" and the code applies −0.12. A model that fits the work better, or is much cheaper, still wins. Also tier-scoped, so avoiding a model in a tier the rule does not name has no effect at all.

Two structural properties are worth more than any individual field. First, rules cannot add candidates. They are consulted after capability filtering and after the pool is built, so a rule can never make the router choose a model that lacks tool calling, lacks vision or has too small a context window — the router's relaxation path, not a hint, is what handles a pool that came out empty. Second, rules only apply to their own class, which is enforced in one function that filters by taskClass === undefined || taskClass === the turn's class. That is what keeps a tweak aimed at intake from quietly re-pricing a build stage.

The fallback ordering is the one place hints act outside scoring: candidates for a fallback are sorted so that preferred models come first, then preferred providers, then everything else, then avoided models, with blended cost breaking ties inside each group. That is why an avoidModelIds entry can look decisive in a failover log and irrelevant in the chosen model's reason — it reordered the retries as well as the score.

Worked examples

Four scenarios, each one showing which term decided the outcome. The arithmetic uses the real weights and constants from the scoring function; the pools are illustrative, because the point is the mechanism rather than a measurement.

1. A rule that pulls a cheaper tier to the front

The shipped dev3d.cost-guard plugin carries two routing rules, and the second one is { "taskClass": "summarize", "tier": "nano" }. On a report stage, where a role's policy targets standard, the walk becomes nano, standard, strong, max, small. A nano model that is simply present in the catalogue — deepseek-flash, in the shipped one — now scores tier affinity 1.00, while the policy's own standard models score 0.85. Once the 0.35 weight is applied that is a gap of 0.0525, and nothing else moved: same class, same capability filter, same cost pressure. This is exactly what the rule asked for, and it is the reason a rule naming a tier has to be read as a routing decision rather than a preference. It also shows why the field deserves care — the rule is scoped to a class, but the tier it names is honoured wherever that class appears.

2. An avoidance list that never fires

The same plugin's no-frontier-for-summaries rule names three models to avoid: gpt-4o, claude-3-5-sonnet-latest and o3-mini. In the shipped catalogue all three are strong models. And the rule's scope is the tier it declares, nano — bonuses and penalties are computed only over models in that tier. So the −0.12 is never applied to any of the three names, and the shipped rule's only real effect on a summarize turn is pulling nano to the front of the walk. This is not a documentation problem, it is what the manifest asks for: an author who wants a model gone has to either name a tier the model actually occupies or accept that −0.12 is a nudge inside one tier — and neither is an exclusion, because a model that fits the work better, or is much cheaper, can still win through 0.12.

Two further details make the field harder to reason about than its name suggests. Bonuses accumulate across rules, so a model preferred by two rules gets +0.16 rather than +0.08. And the fallback ordering treats avoidance as last place rather than impossible, which is why an avoidModelIds entry can look decisive in a failover log while being invisible in the chosen model's reason.

3. Escalation on a shipped role

Take frontend-dev-1: defaultTier standard, byTaskClass.coding standard, escalateAtComplexity 0.6, escalateTo strong. A build stage starts at a complexity of 0.5 before any text is read. The estimate then adds a saturating length term, 0.055 for each named hard signal it finds in the task text, and 0.08 because the turn touches files — so text containing concurren and race condition alone puts the estimate at 0.69, past the threshold before length, revision count and seniority are counted at all. The target becomes strong. Note that escalation assigns rather than increments, and that it happens before the bounds clamp, so the reason clause records the comparison itself: complexity 0.72 >= 0.6 escalated to strong. Two things follow. The same rule means a role with escalateTo equal to its defaultTier escalates to where it already was, which is a no-op that reads like an escalation. And a difficulty of 0.3 — the number a direct conversation is priced at — never escalates any shipped role, because every threshold is at least 0.6.

4. Uptime, and the model nobody measured

Two candidate models in the target tier, otherwise identical in the pool's estimation. One is an OpenRouter slug whose upstream endpoints are all down: uptime 0, penalty 0.30. The other is served by a provider with no health data at all: penalty 0. The unmeasured model wins by 0.30, which is more than five walk steps. That is the intended behaviour rather than a gap — the unmeasured model is not better, it is simply not known to be bad, and the alternative would be a structural advantage for the one provider whose health this office can query. If the first model's endpoints recover to 99 % uptime, the penalty falls to 0.003 and the two are effectively tied on reliability again, at which point fitness and cost decide.

A quality posture on a capped role does nothing. If a role's maxTier is already its origin tier, quality raises the target to the same tier, the clamp finds nothing to clamp, and no reason clause is emitted. The posture is not broken; it had nowhere to go. This is the single most common "the setting has no effect" report about routing, and the answer is to look at the role's own bounds before the installation's posture.

Checking what the router actually did

Every routing decision is recorded with its reasoning, and the record is richer than "model X was chosen". A decision carries the chosen model's provider, id, tier and task class; the clause list explaining it; the chosen model's blended quality, fitness and score; ordered fallbacks; and a considered array holding every candidate that lost, each with its own score and its own reason string.

The reasons are literal, and reading them is the fastest way to answer most routing questions. The clause list joins with an em dash in a fixed order: the class-to-tier mapping, then posture and budget notes, then the escalation comparison, then any bounds clamp, then a capability-relaxation note if the pool had to be widened, then a pin note, then chose <label> (<provider>/<id>), then the score explanation, then the quality provenance. A clause list built from the formats above reads like this:

task 'coding' maps to standard under this role's policy — complexity 0.72 >= 0.6 escalated to strong — chose Claude 3.5 Sonnet (anthropic/claude-3-5-sonnet-latest) — fitness 0.88 for coding, quality 0.82, tier affinity 1.00 at walk position 0, relative cost 0.42, score 0.860 — quality from curated

That example is arithmetic rather than a recording: 0.45 × 0.88 + 0.20 × 0.82 + 0.35 × 1.00 − 0.12 × 0.42 is 0.860, and every number in it is a real one — the fitness is the curated coding fitness of that model, the quality is the strong tier's baseline, and the cost pressure is balanced. The final clause names the sources that produced the quality blend: curated alone on a fresh installation, and curated+learned once this office has finished turns on the model and has its own opinion to add. And a candidate that lost carries one of a small fixed set of reasons, which is where the honest detail lives: excluded, lacks tool calling, lacks vision, context window too small, a pin is in force for this role, tier is not on this policy's walk, scored 0.512 against the chosen 0.860, or tied on score; more expensive, or later by id. Note what those say: a candidate being outscored is distinct from a candidate being out of scope, and the two are worth telling apart before changing a policy.

The Routing and cost panel in the dev3d office console: the routing posture selector with balanced selected, a quality-signals table listing the curated, learned, pooled and uptime sources with their model coverage, and a providers table showing which providers count as configured and how many models each contributes.
Several of the switches on this page in one place: the floor's posture, the sources behind each model's quality score with their real coverage, and the providers whose models can be routed to at all.
Where to lookWhat it holds
TurnRecord.routeThe whole decision for one turn, attached to the transcript and returned by GET /api/runs/:id. Fallbacks and rejected candidates included.
The routing.decision eventThe same decision as it happened, on the WebSocket, which is what a script or a dashboard should watch rather than polling a run.
The transcript's route linePer turn: the tier, the model id, the task class, the reason, and the counts of fallbacks and considered candidates. Enough to notice that a turn was escalated without opening the JSON.
EmployeeState.lastRouteA summary of each employee's last decision — model, provider, tier, reason, when — which is how you see at a glance that one role is persistently running on a tier you did not expect.
Routing & costThe catalogue with per-model tier, price and the sources behind its score; the posture control, which sends setRoutingPosture; and the reported coverage of the external signals, so an unrated catalogue is visible as unrated rather than looking like a considered decision.
servedBy and attemptedRoutesFailover, which is a separate fact from routing: which model actually answered, and every route attempted before it, shaped provider/model: error. A turn can be routed perfectly and served by something else.

The last row is the one that catches people out. Routing and serving are recorded separately, and a provider that dies mid-run produces a transcript where the chosen model and the answering model differ — which is a success for the failover path and a data point the learned-quality layer uses, since a turn whose served model differs from its routed model is recorded as a failure for the routed model. Read both fields before concluding that the router chose badly.

Linked from

Did this page answer your question?