@glasshousehq/openclaw-routing-yaml
v0.1.2
Published
OpenClaw plugin that routes agent turns to different models per task class, driven by a declarative routing.yaml. Implements the `before_model_resolve` hook with deterministic prompt+attachment classification, quarantine guardrails, cost-cliff warnings, a
Maintainers
Readme
openclaw-routing-yaml
An OpenClaw plugin that routes each agent turn to a model picked by a declarative
routing.yamlfile.
What it does
OpenClaw lets you set one primary model per agent. That's fine for a hobby setup, but real agencies and production fleets want different models for different task classes — code-loop work on Codex, long-context recall on Gemini, reasoning on Opus, web research on Sonar Pro — without forking core or writing one-off provider switches per agent.
openclaw-routing-yaml is a single drop-in plugin that does this declaratively:
- You write a
routing.yamldescribing your defaults, guardrails, and per-task-class rules (seeexamples/routing.example.yaml). - The plugin hooks
before_model_resolve— the OpenClaw seam designed for this exact use-case (openclaw/openclaw#91060). - For every agent turn, the plugin classifies the prompt (deterministic
heuristics — no LLM call), looks up the matching rule, and returns a
modelOverride(and optionallyproviderOverride).
It is plugin-only — zero modifications to OpenClaw core, install through ClawHub or npm.
Why a YAML file
- Versioned, lint-able, reviewable. Routing decisions live in source control. Diffs are obvious. PRs can review them.
- Same shape across all your agents. One config, every agent on your fleet gets consistent routing.
- Org-policy friendly. Encode quarantined models, compliance routing (TGA/AHPRA/AFSL/etc.), cost-cliff thresholds, and degraded-mode behaviour declaratively instead of scattering them across agent prompts.
Features
| | |
|---|---|
| Hook | before_model_resolve (typed plugin hook) |
| Classifier | Deterministic, ~200 LoC, no LLM calls |
| Schema | Zod-validated YAML with cross-file invariant checks |
| Quarantine guardrails | Sonnet/Haiku blocked unless rule sets requires_approval |
| Cost-cliff warnings | Structured events when projected tokens cross GPT-5.5 272K / Gemini 200K (configurable per model) |
| Degraded mode | Regulated-client tag + primary unreachable → PARK_AND_NOTIFY event, no silent fallback |
| Fail-soft | Missing/invalid YAML → log warning, plugin no-ops, orchestrator default wins |
| Runtime cost | One YAML read at gateway start, then pure-CPU classify+lookup per turn |
Install
# Via ClawHub (once published).
openclaw plugins install clawhub:@glasshousehq/openclaw-routing-yaml
# Or via npm (pin the version explicitly).
openclaw plugins install npm:@glasshousehq/[email protected]
# Or local development.
openclaw plugins install --link ./openclaw-routing-yamlAfter install, restart the gateway:
openclaw gateway restartVerify the runtime registration:
openclaw plugins inspect routing-yaml --runtime --jsonYou should see before_model_resolve listed under registered hooks.
Configure
1. Write routing.yaml
Put it at ~/.openclaw/workspace/routing.yaml (default path) or wherever
you prefer. Start from the sanitised example:
cp node_modules/openclaw-routing-yaml/examples/routing.example.yaml \
~/.openclaw/workspace/routing.yamlThen edit the model ids, regulated clients, and thresholds to match your
fleet. See the example file for the full
shape and the comments inside src/schema.ts for the
authoritative Zod schema.
2. Enable the plugin
{
plugins: {
entries: {
"routing-yaml": {
enabled: true,
// (Optional) any of these — see configSchema in openclaw.plugin.json.
config: {
routingYamlPath: "~/.openclaw/workspace/routing.yaml",
// Mark this agent as serving a regulated client so degraded-mode
// park-and-notify fires on primary outage.
regulatedClientTag: false,
// Optional family-name -> provider id map. When unset, falls back to
// DEFAULT_PROVIDER_MAP (Anthropic families). Add entries here for
// non-Anthropic providers (OpenAI, Google, Perplexity).
// providerMap: { "gpt-5.3-codex": "openai", "gemini-3.1-pro": "google" },
// Optional family-name -> SDK model id map. When unset, falls back
// to DEFAULT_MODEL_MAP (Anthropic: opus-4.7 -> claude-opus-4-7,
// sonnet-4.6 -> claude-sonnet-4-6, etc). Add per-fleet entries here
// for non-Anthropic SDK strings so they resolve correctly.
// modelMap: {
// "gpt-5.3-codex": "openai/gpt-5.3-codex-2026-05",
// "gemini-3.1-pro": "google/gemini-3.1-pro-001",
// },
// Optional Graphiti-style explicit task class override.
// callerTaskClass: "ner_structured_extraction",
},
hooks: {
// before_model_resolve is a conversation-access hook, so this is
// REQUIRED for non-bundled plugins. See OpenClaw plugin docs.
allowConversationAccess: true,
},
},
},
},
}3. Path resolution order
The plugin resolves routing.yaml in this order:
plugins.entries["routing-yaml"].config.routingYamlPath(explicit)ROUTING_YAML_PATHenvironment variable~/.openclaw/workspace/routing.yaml(default)
If none of those exist, the plugin logs a warning and no-ops — the orchestrator's own default model is used.
Task classifier
The classifier is deterministic and runs zero LLM calls. Heuristics, in priority order:
callerTaskClass(explicit override — used by e.g. Graphiti for NER)regulatedClientTag: trueOR prompt contains regulated keywords (tga,ahpra,afsl,asic,regulated,ndis,qsc, etc.) →compliance_review- Video/audio attachment OR media keywords (
hero video,tiktok,voice agent,create an image) → media class - Image attachment without code block →
vision - Total estimated input tokens > 100,000 →
long_context_recall - Web research keywords (
search the web,research,find sources, etc.) →web_research_fanout - Code-agent keywords (
open the repo,run tests,refactor) →code_agent_loop - Code block markdown AND prompt ≤ 2,000 chars →
code_one_shot(elsecode_agent_loop) - Copy keywords (
draft,write copy,headline,email,subject line) →client_copy - Strategy keywords (
strategy,plan,recommend,approach) →strategy - Default →
text
Source: src/classifier.ts.
Quarantined models
Routes pointing at any model id whose family prefix is sonnet* or haiku*
are quarantined by default. The plugin will emit a QUARANTINE_BLOCKED
event and skip the override unless the rule explicitly sets
requires_approval: blake. If the rule provides fallback_on_decline, the
plugin routes there instead; otherwise the orchestrator's default wins.
Named-exceptions in routing.yaml document approved carve-outs (e.g.
Graphiti NER) but do not by themselves unlock a route at runtime —
a rule still needs requires_approval to take a quarantined model. This
mirrors the Pydantic lint at glasshouse/os/routing/schema.py.
Cost-cliff warnings
When a model id matches a guardrails.cost_cliff_warn.<key> entry AND the
caller-supplied estimateInputTokens() returns a value at-or-above the
threshold, the plugin emits a structured COST_CLIFF_WARN event with the
threshold, estimated tokens, and the human-readable note from the YAML.
The plugin doesn't ship a default token estimator — it accepts one through the runtime config. v1.1 will plumb the orchestrator's token-budget signal into this surface automatically.
Degraded mode
When regulatedClientTag: true AND the runtime signals
isPrimaryUnreachable: () => true, the plugin returns a null override
(orchestrator default-of-last-resort wins) AND emits a PARK_AND_NOTIFY
event carrying notifyChannel from the YAML.
Non-regulated agents skip park-and-notify; the orchestrator's own provider failover stack handles them.
Events emitted
| Kind | When | Caller should |
|---|---|---|
| COST_CLIFF_WARN | Routed model crosses a configured token threshold | Log, optionally surface to operator |
| QUARANTINE_BLOCKED | Rule routed to Sonnet/Haiku without approval | Already handled by plugin (fallback applied) — log for audit |
| APPROVAL_REQUIRED | Rule routed to Sonnet/Haiku with requires_approval | Surface an approval card with fallback_on_decline as the alternative |
| PARK_AND_NOTIFY | Regulated client + primary unreachable | Stop the turn, message the user via notifyChannel, alert Blake |
Wire your handler via runtime.emit in your plugin config (the default
emitter logs each event through api.logger).
Development
pnpm install
pnpm lint # tsc --noEmit (strict mode, no `any`)
pnpm test # vitest
pnpm build # tsc -> dist/60 unit tests covering schema, classifier, router, loader, and the plugin integration surface.
Architecture decisions
- Plugin-only, no core fork — per maintainer ruling on openclaw/openclaw#91060.
- Deterministic classifier — adding an LLM call inside the hook would double-bill every turn. Heuristics + caller-provided overrides are enough.
use:andpipeline:rules —pipeline:returns its FIRST step as the override; the orchestrator drives later stages via its own subagent spawning (Anthropic enforces depth=1).- No silent fallback to Sonnet/Haiku — anywhere, ever. Lint AND runtime refuse.
Release notes
v0.1.2 (2026-06-11)
Fix: the v0.1.0 / v0.1.1 plugin returned modelOverride as the routing.yaml
family-tier id (e.g. opus-4.7), but OpenClaw's before_model_resolve hook
expects the SDK-canonical model id (e.g. claude-opus-4-7). Enabling the
plugin against a real gateway therefore overrode every turn to a non-existent
model ref.
v0.1.2 fixes the contract:
- New
DEFAULT_MODEL_MAPbaked in for the canonical Anthropic families (opus-4.7->claude-opus-4-7,sonnet-4.6->claude-sonnet-4-6,haiku-4.5->claude-haiku-4-5-20251001, plusopus-4.6,opus-4.8,sonnet-4.5). Clean install Just Works on Anthropic-only fleets. - New
DEFAULT_PROVIDER_MAPbaked in so Anthropic families resolve to theanthropicprovider id without per-botproviderMapconfig. - New
modelMapconfig field (and runtimeApplyRuleInput.modelMap) lets a fleet plumb in non-Anthropic SDK strings (OpenAI, Google, Perplexity) or override the default Anthropic mapping (e.g. to route Opus throughclaude-cli/claude-opus-4-7instead). - Per-fleet
modelMapandproviderMapentries always take precedence over the baked-in defaults. Unknown family-tier ids pass through unchanged so the orchestrator either resolves them via its own catalog OR fails loud (which is the signal that the per-fleet config should add a mapping). RouterDecisionnow exposes bothfamilyTierModel(routing.yaml family name, for logging / debugging) andmodelOverride(SDK-canonical id sent to the hook). Plugin decision log lines now includefamily_tier_model.- 5 new router tests + 2 plugin test updates. 65 tests total, all green.
- No
routing.yamlschema change. No breaking change to the OpenClaw hook contract. Plugin id, plugin config schema, and observable side events are backward-compatible additions.
Upgrade path: npm i @glasshousehq/openclaw-routing-yaml@^0.1.2. No config
changes required for Anthropic-only fleets. Fleets using non-Anthropic
providers should add a modelMap entry per family.
v0.1.1 (2026-06-11)
Fix: the v0.1.0 plugin entry used a top-level await to lazily resolve the
host SDK. ESM permits top-level await, but the resulting dist/index.js
failed to load under the OpenClaw plugin loader with:
SyntaxError: await is only valid in async functions and the top level bodies of modulesv0.1.1 refactors the entry to a fully synchronous module shape:
openclawis now declared as an optionalpeerDependency(>=2026.4.0) so it stays host-provided.definePluginEntryis resolved synchronously viacreateRequireat module-load time. No top-level await.- Library-mode fallback preserved: if the host SDK isn't installed (unit tests, standalone library use), the entry identity-passes the definition.
- All 60 existing tests pass unchanged; public API is identical to v0.1.0.
No behaviour changes — routing.yaml, classifier, hook semantics, and emitted events are identical to v0.1.0.
v0.1.0 (2026-06-11)
Initial public release.
License
MIT — © 2026 Glasshouse Group.
