Install
openclaw plugins install clawhub:openclaw-plugin-model-router@latestClassify each turn with a small local model and route it to the best specialist model, narrowing the tool surface per category.
openclaw plugins install clawhub:openclaw-plugin-model-router@latestClassify each incoming turn with a small, fast local model, then:
before_model_resolve → modelOverride).before_prompt_build → toolsAllow).The classification result is cached per run, so both hooks share a single classifier call. If classification fails or times out, the turn falls back to the session's default model and the default tool surface (fail-open).
localModelLean flag, which is all-or-nothing per
agent.cd openclaw-plugin-model-router
npm install
npm run build
openclaw plugins install --link . --force
openclaw plugins enable model-router
Then grant conversation-hook access (required for non-bundled plugins using
before_model_resolve / before_prompt_build), grant LLM model-override trust
(required for the classifier call), and add your routing config — see the config
block below.
openclaw.json){
"plugins": {
"entries": {
"model-router": {
"enabled": true,
"hooks": {
// Required for non-bundled plugins to use conversation hooks.
"allowConversationAccess": true
},
"llm": {
// REQUIRED: the classifier calls api.runtime.llm.complete with an
// explicit model. Without this, every classification throws
// LLM_COMPLETION_NOT_AUTHORIZED and the plugin fails open to the
// default category on every turn.
"allowModelOverride": true,
// RECOMMENDED: scope the classifier to the exact model(s) you intend
// to use. Omit or set ["*"] only if you intentionally want any model.
// Keep this list tight — it is the trust boundary for where prompts go.
"allowedModels": ["ollama/qwen3.5:2b"]
},
"config": {
"enabled": true,
// Optional: verbose per-turn diagnostics (classifier completion
// snapshot + raw classifier output). These can contain prompt-derived
// text, so they are OFF by default — enable only while troubleshooting.
// "debug": true,
// Small, fast classifier (local Ollama 2B).
"classifierModel": "ollama/qwen3.5:2b",
"classifierTimeoutMs": 3000,
// Optional: model to use when classification fails/times out.
// Omit to keep the session's default model.
// "fallbackModel": "ollama/qwen3.8:27b",
// One section per agent, like the main openclaw config's per-agent
// sections. Only agents with a section are routed; every other
// agent is skipped entirely (no classifier call, no model override,
// no tool cap).
"agents": {
"main": {
// category -> { model, toolsAllow? }
"categories": {
"chat": {
"model": "ollama/qwen3.5:4b",
// NOTE: on hosts where web search is a deferred tool (discovered
// via tool_search, invoked via tool_call), include those two as
// well — otherwise current-info turns lose working web search.
"toolsAllow": ["read", "web_search", "tool_search", "tool_call", "message"]
},
"code": {
"model": "ollama/qwen3-coder:30b",
"toolsAllow": ["read", "write", "edit", "exec", "apply_patch", "ls"]
},
"reasoning": {
"model": "ollama/qwen3.8:27b"
// no toolsAllow -> full default surface
},
"general": {
"model": "ollama/qwen3.8:27b"
}
},
// Optional: per-category guidance shown to the classifier.
// Keys should match configured category names.
"categoryDescriptions": {
"chat": "Casual banter, social replies, jokes, lightweight conversation.",
"code": "Writing, debugging, explaining, reviewing, or designing code.",
"reasoning": "Timeless logic, math, planning, tradeoff analysis.",
"general": "Normal assistant work using local/personal context."
},
// Optional: category used when the classifier output does not
// match a known key. Defaults to "general" when omitted.
"defaultCategory": "general"
},
// Another agent with its own routing — sections can diverge per
// agent (different models, categories, tool caps).
"johnny": {
"categories": {
"chat": { "model": "ollama/qwen3.5:4b" },
"general": { "model": "ollama/qwen3.8:27b" }
}
}
}
}
}
}
}
}
Note:
toolsAllowcaps the tool surface for a turn; it cannot grant tools that policy already denies. OmittingtoolsAllowfor a category leaves the default surface unchanged. Multiple hooks' restrictions are intersected.
On hosts where a capability is exposed as a deferred tool — discovered with
tool_search and invoked through tool_call rather than as a directly
callable name — a toolsAllow list that names only the bare capability (e.g.
web_search) will cap the surface and drop the discovery/invocation pair.
The turn then loses working web search even though the model "has" it.
If you rely on per-turn tool narrowing for categories that need current info,
include the deferred-discovery tools in toolsAllow alongside the bare name:
"toolsAllow": ["read", "web_search", "tool_search", "tool_call", "message"]
If you are unsure whether a host uses direct or deferred tool exposure, the
safest option is to omit toolsAllow for that category (leaves the default
surface unchanged) rather than risk capping away a working capability.
The classifier is a real model call via api.runtime.llm.complete with an
explicit model. OpenClaw gates that on host-owned authorization:
plugins.entries.model-router.llm.allowModelOverride: true — required,
or every classification fails with LLM_COMPLETION_NOT_AUTHORIZED and the
plugin silently falls back to defaultCategory (fail-open). You will see
model-router classification failed ... LLM_COMPLETION_NOT_AUTHORIZED in the
gateway log if this is missing.plugins.entries.model-router.llm.allowedModels — recommended. An
allowlist of canonical provider/model refs the classifier may target. Keep
it to exactly the model(s) you intend (e.g. ["ollama/qwen3.5:2b"]). Use
["*"] only if you deliberately want any model; that widens where prompts
can be sent.plugins.entries.model-router.llm.allowedCompletionModels — optional, stricter
knob applied to every completion (including host-resolved defaults). Set it
to the same list as allowedModels if you want a single trust boundary.config.agents.<agentId>. Each
section carries its own categories and optional categoryDescriptions, so
different agents can route differently. Hooks first look up the current hook
context agentId; agents without a section are skipped entirely — no
classifier call, no modelOverride, no toolsAllow cap. OpenClaw continues
with the model and tools selected by that agent's normal config.before_model_resolve runs before model resolution for active agents. It
classifies the prompt and returns { modelOverride }.before_prompt_build runs before the prompt is assembled for active agents.
It reuses the cached classification and returns { toolsAllow } when the
category defines one.{"category": "<label>"}. Parsing is strict: the entire response must be
that one JSON object with a known label — surrounding prose, code fences, or
extra fields are rejected (the turn falls back to defaultCategory). This is
intentional: tolerant substring matching would let user prompt text steer
routing by influencing classifier output.The host validates the config block against the manifest configSchema at
plugin load time and refuses to start the plugin if shape/type errors are
found (unknown top-level keys, a category missing its required model, a
non-string toolsAllow entry, a classifierTimeoutMs below 1000, etc.).
On top of that, the plugin runs startup semantic validation at registration
and logs a model-router config: ... warning for each problem it finds. These
are issues the JSON schema cannot express:
defaultCategory points at a category that does not exist (per agent)
— classification failures and unknown labels will be silently dropped instead
of routed. Keys are compared case-insensitively (the classifier output is
lowercased before lookup, so Chat and chat are the same routing target).categoryDescriptions keys that match no configured category (per
agent) — those descriptions are ignored by the classifier prompt.These warnings appear in the gateway log at plugin startup (and again on every hybrid reload after a config edit). Fix them to avoid silent no-ops or misrouting.
reasoning: "off" so small
thinking models such as ollama/qwen3.5:2b spend their output budget on the
JSON label instead of hidden reasoning. OpenClaw versions affected by
openclaw/openclaw#159276
preserve reasoning: "off" through api.runtime.llm.complete() but do not
serialize it to native Ollama as top-level think: false; the classifier can
then return empty text with stopReason: "length". Until that upstream issue
is fixed in the OpenClaw version you run, apply an equivalent local patch:
In the native Ollama stream compatibility wrapper, read per-call
options?.reasoning, normalize it to an Ollama think value, and let it
override runtime/model defaults before calling createOllamaThinkingWrapper.
In the native Ollama simple-completion request builder, merge the per-call
reasoning override into the /api/chat request after
resolveOllamaTopLevelParams(model), at minimum:
...(options?.reasoning === "off" ? { think: false } : {}),
A fuller local patch may also map minimal to think: "low",
xhigh/adaptive to think: "high", and pass through Ollama-supported
low/medium/high/max values.
toolsAllow is applied by the embedded/CLI/Copilot runtimes. The Codex
app-server harness rejects restrictive values (its tools are thread-scoped);
use an embedded or Copilot runtime if you rely on per-turn tool narrowing.MIT — see LICENSE.