Request routing
A single agent loop cannot be good at everything. The instruction that makes a model a careful editor of files is not the instruction that makes it a careful reader of them, and the tools one needs are the tools the other must not have. The usual answer is to push the difference onto the user: write a longer prompt, spell out the method, remember to say “do not change anything”. That answer scales badly and fails silently — a reasonable sentence gets a shallow answer and nobody can see why.
Contenox puts the difference in the chain instead. A route task reads the request, labels it, and hands it to the loop built for that label. The expertise lives in the configuration, authored once, and the user goes on writing ordinary sentences.
This is the layer above the agentic loop: that page is about how one loop is built, this one is about choosing which loop runs.
The shape
The shipped interactive chains — chain-agent-acp.json, chain-agent-beam.json, chain-agent-contenox.json — all open the same way:
{
"id": "classify_request",
"handler": "route",
"system_instruction": "Classify the user's latest message. Use 'coding_change' for requests to create, edit, migrate, refactor, fix, test, build, or otherwise change code/files. Use 'general' for questions, explanations, brainstorming, setup help... Use 'review_change' for requests to review, audit, critique, or assess code that already exists...",
"execute_config": {
"model": "{{var:alt_model|var:model}}",
"provider": "{{var:alt_provider|var:provider}}",
"tools": []
},
"transition": {
"on_failure": "acp_chat",
"branches": [
{ "operator": "equals", "when": "coding_change", "goto": "coding_chat" },
{ "operator": "equals", "when": "general", "goto": "acp_chat" },
{ "operator": "equals", "when": "review_change", "goto": "review_chat" },
{ "operator": "default", "when": "", "goto": "acp_chat" }
]
}
}
Four properties are worth naming, because each is a decision:
- The classifier holds no tools.
"tools": []. It decides and nothing else; it cannot act on a request it has not yet understood. - It may run on a smaller model.
{{var:alt_model|var:model}}falls back to the main model, but labelling one sentence is cheap work and does not need the expensive one. on_failurenames a real loop. If classification fails, the request goes to the general assistant rather than to an error. A router that can refuse to route is a new way to fail.- The
defaultbranch is the safe branch. An unrecognised label lands on the least-powerful loop, never the most.
What a specialist is
A branch target is not a differently-worded prompt. Each loop carries its own:
- System instruction — the method, stated as a contract: what counts as input, what decision is being made, what the output must contain.
- Tool scope —
toolsselects toolsets;hide_toolswithholds individual tools bytoolset.toolname. This is enforced when a call executes, not merely by omitting the tool from what the model was offered, so a model that asks for a withheld tool anyway still cannot run it. - Budget — the
edge_traversed_at_leastceiling on its own chat→tools edge, sized for the work that loop does. - Recovery — where it goes when it stalls, and with how much power. Each stage after a failure gets fewer capabilities and a narrower mandate.
The review loop is the clearest illustration. It withholds every mutating tool:
"hide_tools": [
"local_fs.write_file", "local_fs.edit_file", "local_fs.sed",
"git.git_add", "git.git_commit", "git.git_checkout_branch", "git.git_restore"
]
and drops the network entirely with "tools": ["*", "!webtools"]. “This loop does not modify anything” is therefore a property of the configuration, not a promise in a prompt — and the same withholding is repeated on the paired execute_tool_calls task, because a tool withheld only where the model is asked would still run where calls are executed.
This is not a coding feature
Routing is what lets one deployment serve requests that need different treatment, in any domain:
- A support assistant routes billing questions to a loop with the billing API and no ability to write, and account changes to a loop that can write but pauses for approval.
- A moderation gate (see the story) is a router whose unsafe branch simply never reaches the expensive model.
- A data workflow routes “summarise this” and “extract these fields into JSON” to loops with different output contracts, so the caller gets parseable output without asking for it every time.
- A governed deployment routes anything touching regulated data to a loop bound by a stricter HITL policy, regardless of how the request was phrased.
In each case the point is the same: the workflow author knows something the user should not have to restate — which tools this kind of request may use, what a good answer looks like, what must never happen. The router is where that knowledge is applied.
Adding a branch
- Name the label and add one sentence to the classifier’s instruction describing it. Say what belongs to it and what does not — the sentence that keeps “review and then fix this” out of the review branch is the one that earns its place.
- Add the
equalsbranch, above thedefaultbranch. - Author the loop: a
chat_completiontask with the method as its instruction and its tool scope declared, plus anexecute_tool_callstask withinput_varnaming the chat task and adefaultbranch back to it. - Give it a ceiling and a failure exit — an
edge_traversed_at_leastbranch ahead of thetool_callbranch, pointing at a recovery task orsummarise_failure.
Then check it: contenox vet path/to/chain.json refuses a chain whose branches name tasks that do not exist, and a listener that can never fire.
Reading which branch ran
The chain is data, so the route it took is observable rather than inferred. Each task attempt is logged with its id — in the terminal UI, ~/.contenox/beam.log records operation=task_attempt subject=review_chat — and contenox events shows the same journey for a dispatched run. When an answer disappoints, the first question is not “how should I have phrased it” but “which loop got it”, and that question has an answer you can read.