How a ticket picks its brain: semantic engine policy across Claude and Codex¶
If you have read the orchestration lifecycle post, you know the unit of work in my AI build loop is a ticket: a contract with a goal, a scope, a ## Touches block, acceptance criteria, and verification commands. An agent reads one ticket in a fresh context and writes the code. That post walked how a ticket is scheduled and run. It skipped one small field on the ticket, and that field is the whole subject of this post.
The field is the answer to a question every multi-agent build has to answer eventually: which model runs this ticket? A year ago my answer was the obvious one -- I wrote the model name into the ticket. "Use Opus for this one." "Sonnet is fine here." It worked until it didn't. The day a provider shipped a new model, or I wanted to try the same backlog on a second provider, every ticket that named a model was suddenly wrong, and there was no single place to fix them.
So I stopped naming models. A ticket now describes the kind of capacity it wants, in plain semantic terms, and a small control plane under the orchestration control plane maps that request onto a concrete engine and model for whatever provider the current environment is wired to. This post is about that indirection: what the ticket says, how the map turns it into a real model across both Claude and Codex, and why decoupling "what capacity does this work need" from "which vendor model runs today" is the single most transferable idea here for anyone running an AI workflow across more than one provider.
Everything below is grounded in real files you can open: the policy doc the workflow docs, the control-plane map in the orchestration control plane, the dispatcher the orchestration control plane, the per-provider adapters under the orchestration control plane, and the frontmatter contract spelled out in CLAUDE.md.
A ticket asks for capacity, not a model¶
The routing input is four fields in the ticket's YAML frontmatter. Here is a ticket asking for a capable, autonomous run with a big context window:
```yaml¶
state: confirmed source: ... priority: P1 agent_tier: high agent_effort: high agent_context: large agent_profile: autonomous
Each field is a closed set of words, not a free-text box, and that is the point -- a closed vocabulary is something a script can map deterministically. The meanings come straight from the workflow docs and CLAUDE.md:
agent_tier-- coarse capability request:low,medium,high, ormax. This is the "how smart a model do I need" dial.agent_effort-- reasoning and work budget:low,medium,high, ormax. How hard the agent should grind on the problem.agent_context-- context-window request:tiny,normal,large, orfull. How much of the repo the agent should be handed up front.agent_profile-- behavior envelope:safe,review-only, orautonomous. How much the agent is allowed to do -- read-only, write-with-care, or run unattended.
Read those four out loud and notice what is missing: a vendor name. The ticket says "I need high capacity, high effort, a large context, and permission to run autonomously." It does not say "use Opus" or "use GPT-5.5." It describes the shape of the work, and the policy doc is explicit that this is the preferred style: "Prefer agent_tier: and agent_effort: over provider-specific model IDs so the orchestration control plane can map the work to the right current engine/model."
There is a deliberate escape hatch for the rare ticket that genuinely needs one specific model -- a agent_engine: field (for example codex) paired with a agent_model: field. CLAUDE.md calls agent_model: a "rare escape hatch" and requires it be paired with agent_engine:. The existence of the hatch is the proof of the rule: naming a model is the exception you have to justify, not the default you reach for.
The most important thing to internalize as a newcomer: these fields are a request, not a command. A ticket cannot reach out and start a model. It states what it needs, and the control plane decides what that means today.
The tier-to-model map: one table, two providers¶
So how does agent_tier: high become an actual model? Through a small mapping table. It lives in the orchestration control plane under a models block, and the exact same table is implemented in the select_model function in the orchestration control plane (the config file is the readable reference; the adapter is what runs). Here it is, verbatim from the repo:
agent_tier |
Claude model | Codex model |
|---|---|---|
low |
claude-haiku-4-5-20251001 |
gpt-5.4-mini |
medium |
claude-sonnet-4-6 |
gpt-5.4 |
high |
claude-sonnet-4-6 |
gpt-5.5 |
max |
claude-opus-4-8 |
gpt-5.5 |
That is the entire decision. A ticket that says agent_tier: max runs on claude-opus-4-8 if the environment is configured for Claude, or gpt-5.5 if it is configured for Codex. The ticket did not change. The map did the translating.
Tier is the dial that picks the model. The other three semantic fields steer different knobs, and it is worth being precise about what each one actually does, because "they all just pick a model" would be wrong:
agent_contextcontrols how much of the repo gets pasted into the agent's prompt. In the orchestration control plane, the prompt-composer adds the project rules (CLAUDE.md) and workflow config for anything abovetiny, and adds the project brief and roadmap on top forlargeorfull. Soagent_contextis a real, observable knob: it changes the bytes the model sees before it starts.agent_profilecontrols the execution envelope. This is clearest in the Codex adapter (the orchestration control plane): asafeorautonomousprofile runs the CLI with sandbox-bypass approvals, whilereview-onlyruns it read-only with approvals turned off. The profile decides what the agent is allowed to touch, independent of which model it is.agent_effortis carried into the spec the workflow layer reads and recorded in the result envelope (more on both below). It is the work-budget request that travels with the run.
The clean separation is the design: tier picks the brain, context picks how much it knows going in, profile picks what it can do, effort picks how hard it tries. Four orthogonal dials, four closed vocabularies, one map per provider.
The the orchestration control plane control plane is a small, legible surface¶
The map is half the story. The other half is the thin layer that reads the request, applies the map, runs the provider, and reports back in a uniform shape. That layer is the orchestration control plane directory, and the nice thing about it is that you can read all of it in an afternoon.
The front door is the orchestration control plane, a single bash dispatcher invoked with --mode <mode>. Its modes fall into three families, and the families are the key to understanding it:
- Direct-agent modes --
ask,inspect,patch,debug,diff-review,draft-ticket. These compose a prompt and call a provider CLI directly. You pick the engine with--engine claude|codex|auto, andautoresolves toclaude. Use these when you want one bounded act -- ask a question, inspect a file, draft a ticket -- without spinning up the whole ticket lifecycle. - Workflow-delegating modes --
status,run-ticket,review-ticket,ship-ticket,plan-phase,run-batch,list-backlog,retro. These hand off to the orchestrator dispatcher the orchestrator dispatcher -- the same staged machinery the lifecycle post describes. When one of these runs autonomously,run.shpacks the semantic request (engine, tier, effort, context, profile, and the model map itself) into a JSON blob it exports asWORKFLOW_AGENT_SPEC, which is the bridge that carries the ticket's capacity request into the workflow layer. - Utility modes --
doctor(probe which tools are reachable without calling auth) andprepare-secrets(resolve credentials).
The per-provider details live in small adapters under the orchestration control plane, one concern each:
claude.shruns the Claude CLI. It asksselect_modelfor the model that matches the tier, then runsclaude -p --model <model>. It also does one provider-specific bit of hygiene worth seeing: it unsetsANTHROPIC_API_KEYfor that one invocation, because the CLI would otherwise prefer an API key over the Max-plan OAuth session and bill the wrong account.codex.shruns the Codex CLI. Same model lookup, plus the profile-to-sandbox mapping described above, thencodex exec --model <model>.common.shis the shared library both adapters lean on: theselect_modelmap, the dotenv and secret-cache handling, the command resolution, and the result-envelope writer.workflow.shis the thin shim that turns a workflow mode into the matching the orchestrator dispatcher verb.
Every run, no matter which engine or mode, ends by emitting the same JSON result envelope (written by write_envelope in common.sh). Its shape:
```json { "ok": true, "engine": "claude", "mode": "patch", "tier": "high", "effort": "high", "context": "large", "profile": "safe", "session_id": "...", "summary": "...", "changes": [], "risks": [], "followups": [], "artifacts": { "prompt": "...", "log": "...", "output": "..." } }
That uniform envelope is what makes the whole thing legible. Whether the work ran on Opus or GPT-5.5, the caller gets back the same fields -- the resolved tier/effort/context/profile, an ok flag, a summary, and pointers to the prompt and log artifacts on disk. The provider is an implementation detail hidden behind a stable result shape.
One naming note so you are not confused when you grep the code: the operator surface you actually drive day to day is the /dev-* slash commands -- /dev-ticket, /dev-review, /dev-ship, /dev-status, /dev-retro, and friends, which map onto the workflow dispatcher verbs. The live command definitions now sit under the local agent configuration for Claude and .codex/prompts/ for Codex, and those /dev-* commands over the orchestration control plane control plane are the current way in.
Why this pattern travels¶
Strip away the specific filenames and the transferable idea is one sentence: decouple "what capacity does this work need" from "which vendor model runs today."
That decoupling buys three things that matter the moment you run a real workflow over time:
- The ticket spec outlives any one provider's model lineup. Providers rename, deprecate, and replace models on their own schedule. A ticket that says
agent_tier: highdoes not care -- the day a model is retired, you edit one row in the orchestration control plane and every ticket re-routes. A ticket that said "use sonnet-4-5" would be a landmine waiting for that day. - A second provider is a column, not a rewrite. Because the request is semantic, supporting Codex alongside Claude meant adding a
codexcolumn to the map and acodex.shadapter -- not rewriting a single ticket. The backlog is provider-agnostic by construction. - Routing policy lives in one auditable place. "What does
highmean right now?" has a file answer, not a scattered-across-200-tickets answer. You can change your whole fleet's behavior -- escalate everymediumticket to a bigger model during a hard week -- by editing the map.
If you take one thing from this post into your own setup, take this: the names you hard-code are the names you will have to chase later. Spend the small up-front cost of a semantic vocabulary and a mapping table, and you buy yourself a backlog that does not rot every time a model ships.
The honest edge¶
I would be selling you something if I stopped at "and then the indirection makes it all future-proof." It does not. The indirection moves the decision; it does not eliminate it.
The tier-to-model table in the orchestration control plane is hand-maintained. Nothing derives it; a person typed those model IDs in, and a person has to keep them current. That has a sharp failure mode: a stale or wrong row silently mis-routes work. If max still points at last quarter's model after the provider shipped a better one, every agent_tier: max ticket quietly runs on the old model and nothing errors -- the run looks fine, it is just dumber than you asked for. A wrong model ID is worse: it fails loudly, but only when a ticket happens to request that tier. The semantic layer makes the request durable; it does nothing to guarantee the map behind it is correct.
Two more edges worth seeing plainly:
- Tier is coarse. Look back at the table: on Claude,
mediumandhighboth resolve toclaude-sonnet-4-6today, and on Codex,highandmaxboth resolve togpt-5.5. Two different requests collapse to the same model. That is fine -- it just means the dial has fewer real positions than it looks like, and a ticket that escalates frommediumtohighmay get the same brain it had before. The vocabulary has more resolution than the current map spends. - Cross-provider parity is an assumption, not a guarantee. The map lines up
highon Claude withhighon Codex, but nobody promisedclaude-sonnet-4-6andgpt-5.5behave identically on your tickets. The semantic request is portable; the results across providers are only as comparable as the two model lineups actually are.
None of this is an argument against the pattern -- it is the same shape as every honest tradeoff in this project. The indirection takes a decision that was smeared across every ticket and concentrates it in one table you can read, review, and version. That is a strictly better place for the decision to live. It is just still a decision, owned by a human, that can be wrong. The map is the thing to watch.
That is how a ticket picks its brain: it states the kind of capacity it needs in four closed-vocabulary fields, and a small, readable control plane under the orchestration control plane maps that request onto a concrete model for whichever provider is wired up today -- emitting the same JSON envelope no matter which one ran. If you came here from the orchestration lifecycle post, this is the routing field that post glossed; and if you want the other half of how an autonomous run stays trustworthy -- the lint wall and review gates that judge what the chosen model actually produced -- that is Review surfaces and the AI-first guardrails.
Or: subscribe for more posts on building autonomous AI development infrastructure.