Provenance. Source-verified against the OpenClaw repo (
src/plugins/hook-before-agent-start.types.ts,src/plugins/hook-types.ts,src/agents/embedded-agent-runner/run.ts,docs/plugins/hooks.md,docs/concepts/model-providers.md) and adversarially checked. Items not byte-confirmed are tagged UNCONFIRMED. Companion toQUALITY-GATED-ROUTER.md.
READ FIRST — verdict, caveats, and one correction¶
Verdict: GO, with caveats. Maturity risk: MEDIUM (API churn, not abandonment). The hook signature and the custom-provider config are source-confirmed and fit anvil's design exactly. Build the router-side preset support now; validate two live gaps before writing/shipping the plugin.
Environment for validation. The validate-first gaps below should be confirmed against a live
OpenClaw install. The router serves (:30000 heavy, :30001 fast) must be reachable from the
gateway. Confirming the gaps on a real install eliminates a fresh stand-up for each question.
Correction to record (vs the prior finding). The earlier research said before_model_resolve
fires "per agent turn." The source-level dive refines this: it fires once per run, above the attempt
loop, and does not re-fire on a before_agent_finalize "revise" retry. For a chat-bridge harness
a "run" is plausibly one user message (which is exactly the cadence work-class routing needs — the
user's message determines the work-class), but this must be confirmed live: that a run maps to a
single user message and doesn't span turns, and that multi-step internal model calls within a run
share one intent (fine for us). The "classify each turn" premise rests on this.
Two CRITICAL validate-first gaps (write no build code against them until settled):
1. Wire model value — does the outbound HTTP request carry the bare id (planning) or the full
ref (anvil/planning)? Capture a real outbound request. (anvil should accept both to be safe.)
2. Firing cadence — confirm before_model_resolve fires per user message (see correction above),
by logging every fire with ctx.runId/ctx.sessionKey across a multi-turn conversation.
API-churn risk + mitigation. OpenClaw is real, MIT, and very active, but young (CalVer
v2026.6.x, no semver 1.0, multiple releases/week) and the extension surface is mid-refactor —
before_agent_start is already "compatibility-only" in favor of the before_model_resolve we target,
so our hook could shift on the same cadence. (The reported star count was extremely high; treat the
exact number as unverified — the load-bearing point is "very active but churning," not the figure.)
Mitigation is the focus-not-couple architecture, and it's genuine: the router core stays
protocol-standard with zero OpenClaw import; the OpenClaw piece is a ~50-line, one-hook,
swappable adapter plugin. If the hook churns, only the adapter changes; if OpenClaw stalls, the
router is unaffected. Guardrails: pin the pluginApi compat + OpenClaw release; keep all
OpenClaw-specific code in the plugin package; verify+fallback lives in the router (the design
already assumes this — OpenClaw has no response-swap hook, so this is the only correct place).
Status: buildable spec, rev 2026-06-29. Derived from source-verified facet findings (
src/plugins/hook-before-agent-start.types.ts,src/plugins/hook-types.ts,src/agents/embedded-agent-runner/run.ts,docs/plugins/hooks.md,docs/concepts/model-providers.md,docs/gateway/config-tools.md) and anvil's owndocs/QUALITY-GATED-ROUTER.md. Every item not byte-confirmed against OpenClaw source/docs is tagged UNCONFIRMED — validate before relying on in build code.
0. What is CONFIRMED (build against these)¶
- Hook signature (source-verified).
before_model_resolveevent ={ prompt: string; attachments?: { kind: "image"|"video"|"audio"|"document"|"other"; mimeType?: string }[] }; result ={ modelOverride?: string; providerOverride?: string }. Types live insrc/plugins/hook-before-agent-start.types.ts(file named for the @deprecatedbefore_agent_start, but holds the currentbefore_model_resolvetypes). Handler is(event, ctx) => result,ctx: PluginHookAgentContext. - Fires once per run, above the attempt loop (
run.tsL1033resolveHookModelSelection, applied atsetup.tsL98–103). It does not re-fire on abefore_agent_finalize"revise" retry (the revise path reuses the resolved provider/modelId;run.tsL4063–4078). - Provider registration.
models.providers.<id>acceptsbaseUrl,apiKey,api(enumopenai-completions | openai-responses | anthropic-messages | google-generative-ai),headers, inlinemodels[].models.mode: "merge"(default) ADDS to the built-in catalog. Models are referenced everywhere as the string"<providerId>/<modelId>". Docs say verbatim: self-hosted/v1/chat/completions(MLX/vLLM/SGLang) → useopenai-completions. - Preset-as-model. Any arbitrary string declared as a model
idin a provider'smodels[]becomes a first-class selectable model"<providerId>/<modelId>". - Per-model sampling/thinking knobs.
agents.defaults.models["anvil/<preset>"].params.chat_template_kwargsand.params.extra_body— covers anvil gotcha #5 (enable_thinking:false). - Verify primitives (client-side).
llm_output(observe-only:assistantTexts[],usage),before_agent_finalize(decision:{ action?: "continue"|"revise"|"finalize"; reason?; retry?: { instruction; idempotencyKey?; maxAttempts? } }, capMAX_BEFORE_AGENT_FINALIZE_REVISIONS=3),model_call_ended(transport telemetry). Noafter_model_response/response-swap hook. - Native failover (
agents.defaults.model.fallbacks) triggers on transport-class errors only (auth/429/overloaded/timeout/billing) — never on a correctness/quality verdict. LIVE-CONFIRMED CAVEAT (2026-07-01, see §7 below): the fallback walk itself fires correctly on anvil's exhaustion-503, but when the failing attempt was resolved viabefore_model_resolve'sproviderOverride, the fallback attempts also resolve through that same overridden provider — so the native provider is never actually reached. Treat this as a live-confirmed gap, not the "safety net" §1/§4 previously assumed. - Gating. Non-bundled plugins using
before_model_resolveMUST setplugins.entries.<id>.hooks.allowConversationAccess=true. Prompt-mutating hooks additionally needallowPromptInjection. - Trust/deploy. MIT (OpenClaw Foundation). Node 22.19+/24, TS ESM. Gateway defaults to loopback; a loopback anvil endpoint needs no TLS and no gateway auth. Plugin install = "running code", gated by
security.installPolicy+plugins.allow/plugins.deny; config change requiresopenclaw gateway restart.
1. Reference before_model_resolve plugin (Tier-0 classify → preset)¶
The plugin classifies the current turn and emits an anvil preset id. Because the event carries only prompt + attachment metadata (CONFIRMED — "No session messages are available yet in this phase"), the classifier is a lightweight heuristic over prompt text + attachment kinds, not a full-context judge.
// index.ts — package "type":"module", ESM, Node 22.19+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
// Closed preset enum = anvil's wire vocabulary (must match models[] ids in §2 and the router).
type AnvilPreset = "planning" | "quick-edit" | "review" | "chat" | "long-context";
function classify(prompt: string, attachments?: { kind: string }[]): AnvilPreset {
const p = prompt.toLowerCase();
if (attachments?.some(a => a.kind === "image")) return "review"; // multimodal → capable tier
if (prompt.length > 24_000) return "long-context";
if (/\b(plan|design|decompose|architect|break (this )?down)\b/.test(p)) return "planning";
if (/\b(review|critique|audit|find bugs?)\b/.test(p)) return "review";
if (/\b(fix|edit|rename|tweak|typo|small change)\b/.test(p)) return "quick-edit";
return "chat"; // safe default; router biases ambiguous → safer/cloud tier
}
export default definePluginEntry({
id: "openclaw-anvil-intent-router", // MUST match the packaged plugin id + the plugins.entries key below
name: "Anvil intent router",
register(api) {
api.on(
"before_model_resolve",
(event, _ctx) => ({
providerOverride: "anvil",
modelOverride: classify(event.prompt, event.attachments),
}),
{ priority: 50 /*, timeoutMs: 50 */ }
);
},
});
UNCONFIRMED — must validate on a live gateway before shipping:
- Firing cadence (load-bearing). Source structure says once-per-run, and the doc comment says "before session messages load" — confirm empirically that before_model_resolve fires per agent turn (so per-turn classification is real) and not once per session. The entire "classify each turn" premise hinges on this. Validate by logging every fire with ctx.runId/ctx.sessionKey across a multi-turn conversation.
- registerEntry arg shape. Two doc/example forms appear: object form definePluginEntry({ id, name, register(api){...} }) and callback form definePluginEntry(async (api) => {...}). Pin the form against the installed SDK's plugin-entry export.
- ctx field availability. PluginHookAgentContext is documented to include workspaceDir, channel, contextTokenBudget, etc., but which fields are populated at the before_model_resolve phase is UNCONFIRMED — the classifier above deliberately uses only event to stay safe.
Packaging (CONFIRMED shape, version string UNCONFIRMED):
// package.json
{ "type": "module",
"openclaw": { "extensions": ["./index.ts"],
"compat": { "pluginApi": ">=2026.3.24-beta.2" } } } // UNCONFIRMED exact floor; before_model_resolve reportedly added ~2026.4.21 — confirm against CHANGELOG/release tags and pin.
// openclaw.plugin.json
{ "id": "openclaw-anvil-intent-router", "activation": { "onStartup": true } }
Install: openclaw plugins install --link ./local (dev) or clawhub:<org>/openclaw-anvil-intent-router; then openclaw gateway restart; verify openclaw plugins inspect openclaw-anvil-intent-router --runtime --json. --link is required on OpenClaw >=2026.6.11 — that compiled-runtime loader rejects a copy-install (openclaw plugins install <path> without the flag) for TypeScript/compiled plugins like this one; only a linked (symlinked) install is accepted.
2. OpenClaw provider config recipe (point at anvil-serving)¶
contextWindowMUST match the real routed tier's window — see the live-confirmed failure mode below the recipe before changing these values.
// ~/.openclaw/openclaw.json (JSON5)
{
models: {
mode: "merge", // CONFIRMED default; ADDS to built-in catalog
providers: {
anvil: {
baseUrl: "http://127.0.0.1:8000/v1", // anvil-serving OpenAI-compat front door (loopback ⇒ no TLS/auth)
apiKey: "${ANVIL_API_KEY}", // env interpolation CONFIRMED; anvil may ignore on loopback
api: "openai-completions", // CONFIRMED for self-hosted vLLM/SGLang
models: [
{ id: "planning", name: "Anvil · Planning", reasoning: false, input: ["text"], contextWindow: 131072, maxTokens: 32000 },
{ id: "quick-edit", name: "Anvil · Quick Edit", reasoning: false, input: ["text"], contextWindow: 131072, maxTokens: 8192 },
{ id: "review", name: "Anvil · Review", reasoning: false, input: ["text","image"], contextWindow: 131072, maxTokens: 16000 },
{ id: "chat", name: "Anvil · Chat", reasoning: false, input: ["text"], contextWindow: 131072, maxTokens: 8192 },
{ id: "long-context", name: "Anvil · Long Context", reasoning: false, input: ["text"], contextWindow: 131072, maxTokens: 16000 }
]
}
}
},
agents: { defaults: {
// Default slot when the plugin is absent → router's own Tier-0 classifier still applies.
model: { primary: "anvil/chat" },
// anvil gotcha #5 (thinking-by-default models) — per-preset:
models: {
"anvil/planning": { params: { chat_template_kwargs: { enable_thinking: false } } },
"anvil/long-context": { params: { chat_template_kwargs: { enable_thinking: false } } }
}
} },
plugins: { entries: { "openclaw-anvil-intent-router": { hooks: { allowConversationAccess: true } } } } // key = packaged plugin id (CONFIRMED required gate)
}
Why every preset above declares 131072 (v0.7.1 — LIVE-CONFIRMED FAILURE MODE,
2026-07-02). contextWindow must be declared as the LARGEST context window among
the tiers a preset can actually route to, not the smallest/typical one — for the
reference deploy (configs/example.toml) that is heavy-local's context_limit =
131072, since every preset's candidate pool either routes to heavy-local directly
(review, planning, long-context) or can escalate to it as a fallback
(chat, quick-edit: [fast-local, heavy-local]). An earlier version of this recipe
declared chat/quick-edit at 32000 (matching only fast-local's window) — that
understated value caused a live incident:
- OpenClaw computes
max_completion_tokens = declared contextWindow − actual prompt tokens, clamped to a floor of 1 — it does not reject an oversized prompt. - A real conversation's prompt grew past the understated
32000(an actual ~43k-token payload vs. the declared 32768-class window). Every subsequent turn'smax_completion_tokenscomputed negative, floored to 1. - The local model correctly honored the 1-token cap and returned exactly one token with
finish_reason: "length"— genuinely correct, caller-capped behavior. - Pre-v0.7.1, anvil's
NotTruncatedverifier had no way to distinguish "the model obeyed an explicit caller cap" from "an unexpected truncation" — it hard-failed every such response on every tier, producing a 503 exhaustion on every turn. Worse, the repeated verify-failures tripped the circuit breaker (fallback.CircuitBreaker), blacking out the whole work-class (not just the offending turn) for the cooldown window — collateral damage to otherwise-healthy traffic. - The operator-visible 503 error text ("gated candidates [...] are unbound. Configure
that tier's credentials/endpoint...") pointed at credentials/reachability, which was
wrong — the tiers were bound and reachable the whole time — costing real
debugging time twice before the
contextWindowmisdeclaration was found.
v0.7.1 fixes the router side (a caller-capped length/max_tokens stop with
non-empty content now PASSES NotTruncated — see anvil_serving/router/verify.py — so
it no longer 503s or trips the breaker) but the contextWindow values above are still
the correct fix on the OpenClaw side: declaring the true largest routed window means
OpenClaw computes a realistic completion budget in the first place, rather than relying
on the router to absorb an artificially starved budget every turn. Any harness that
computes its own completion-token budget from a declared context window has the same
failure shape — a 1-token (or otherwise pathologically small) "availability probe" is
not exotic; it is a natural consequence of any provider-side context-window
misdeclaration once the real prompt exceeds it.
3. Preset / model-id contract¶
- Vocabulary: closed enum
{ planning, quick-edit, review, chat, long-context }(matchesdocs/QUALITY-GATED-ROUTER.md§9). Same strings in three places: pluginmodelOverride, providermodels[].id, anvil router's acceptedmodelnames. - Selection string inside OpenClaw is
"anvil/<preset>"(CONFIRMED ref format). - Wire value — UNCONFIRMED (CRITICAL build gap). It is NOT documented whether the upstream HTTP
modelfield receives the bare id ("planning") or the full ref ("anvil/planning"). Theopenai-completionsconvention is the bare id, so anvil-serving must acceptplanning/quick-edit/… as servable model names — but capture an actual outbound request (local echo server or proxy log) to confirm before building the router's model-name parser. To be robust, anvil should accept BOTH the bare preset and theanvil/<preset>form. This is the single most important thing to verify hands-on. /v1/modelsdiscovery: anvil should serve the preset tokens with human names so they also surface for non-plugin/closed harnesses (Tier-1). Whether OpenClaw auto-imports a custom provider's catalog from/v1/modelsvs requiring the inlinemodels[]is UNCONFIRMED — the inlinemodels[]above is the safe, CONFIRMED path; treat catalog auto-pull as a bonus to verify.- Override resolution — UNCONFIRMED. Whether
modelOverridemust name a model already in the resolved catalog (i.e. it MUST be pre-registered inmodels[]) or can be an arbitrary opaque preset id is not confirmed. Build defensively: always pre-register every preset inmodels[](§2).
4. Where each stage lives (client plugin vs router)¶
| Stage | Lives in | Why (source-grounded) |
|---|---|---|
| Tier-0 classify | Client plugin (before_model_resolve), with the router's own classifier as the floor |
Plugin sees the raw turn prompt and can emit a per-turn preset. Closed harnesses (Claude Code/Codex) lack the hook, so the router must still classify. |
| Intent → (model, tier, params) | Router | Preset is opaque to OpenClaw; only anvil owns the quality profile + tier mapping. |
| Verify (cheap structural) | Router (primary); optional client mirror via llm_output |
llm_output is observe-only; it can feed a client-side verdict but cannot change the served response. Inline verify on the hot path belongs in the router. |
| Cross-tier quality fallback (within a turn) | Router ONLY | CONFIRMED: before_model_resolve fires once per run; before_agent_finalize "revise" retries the same model (no provider/model field), cap 3; native failover excludes quality verdicts. The client cannot escalate tier mid-turn on a quality miss. |
| Client-side escalation | Client plugin (next-turn only, optional) | A plugin may store an llm_output/model_call_ended verdict and bias the NEXT turn's before_model_resolve upward. Same-turn before_agent_finalize "revise" can only nudge the same model with an instruction string. |
Net: this matches anvil's existing design (QUALITY-GATED-ROUTER.md §7) — verify+fallback is a router responsibility; the OpenClaw plugin is a thin Tier-0 classifier that pushes per-turn intent to the wire. No design change needed.
5. MVP build steps¶
- Router accepts presets (no OpenClaw needed). Make anvil-serving's OpenAI front door accept
{planning,quick-edit,review,chat,long-context}(andanvil/<preset>) asmodel, map to tier, serve. Add/v1/modelslisting the presets. (anvil M0–M1.) - Use the available OpenClaw install on the gateway (already installed; no fresh stand-up). Confirm/pin its version (
openclaw --version), then add the §2 provider block pointing at the router. Smoke-testopenclaw models listshowsanvil/*; send a turn withagents.defaults.model.primary="anvil/chat"; capture the outbound request to settle §3 wire-value gap. (Reproducing elsewhere:npm i -g openclaw@<pinned stable>+openclaw onboard --install-daemon.) - Reference plugin. Build §1,
openclaw plugins install --link ./local, setallowConversationAccess=true, restart. Log everybefore_model_resolvefire → confirm per-turn cadence. Verify a returnedmodelOverrideactually routes the turn to the anvil endpoint with the expected wire model. - Router-side verify+fallback. Implement cheap structural verify + tier fallback server-side (anvil M2). Optionally add a client
llm_outputobserver that logs verdicts for next-turn biasing. - Harden + publish. Pin
pluginApicompat once confirmed; publish plugin to ClawHub; document thesecurity.installPolicy/plugins.allowinstall path.
6. Open questions needing hands-on validation (all UNCONFIRMED)¶
- Wire
modelfield = bare id vsanvil/<preset>(step 2 capture). CRITICAL. before_model_resolveper-turn vs per-session firing cadence (step 3 logging). CRITICAL.- Whether
modelOverridemust reference a catalog-registered model (build defensively: always register). - Exact
pluginApicompat floor string + the release that introducedbefore_model_resolve(pin from CHANGELOG/tags). definePluginEntryarg form (object vs callback) in the installed SDK.- Whether a
before_agent_finalize"revise" instruction can be parsed by the router to coerce escalation (likely no clean channel — treat router fallback as the real path). - Plugin runtime config/secret access (
plugins.entries.<id>.config) and whether plugins are sandboxed (affects shipping-safety claims). - Timeout key name(s) on the provider (
timeoutSecondsvstimeoutMs) — not byte-confirmed.
7. LIVE-CONFIRMED DEFECT (2026-07-01): the anvil-503 native-failover loop¶
Symptom (observed live, real OpenClaw agent turn, v0.6.0): before_model_resolve set
providerOverride:"anvil" for a local-preferred-class turn (quick-edit/review/chat/long-context —
plugins/openclaw-anvil-intent-router's T008 upfront split). anvil returned 503 ("no
quality-gated tier is available for this request" — the keyless-handoff signal, ADR-0001).
OpenClaw's native failover (agents.defaults.model.fallbacks -> [openai/gpt-5.5,
openai/gpt-5.4-mini]) DID fire (so the 503-is-a-transport-failure-trigger assumption from
docs/PLAN-advise-and-defer.md Phase 1 is confirmed correct) — but both configured fallback
models also 503'd through the anvil provider, never reaching the native cloud provider. The
agent turn ended in "couldn't generate a response" instead of a graceful cloud handoff.
Root cause (source-grounded, not guessed). §0 above is source-confirmed: before_model_resolve
"fires once per run, above the attempt loop" (run.ts L1033 resolveHookModelSelection, applied at
setup.ts L98–103). The live symptom is consistent with that resolution — specifically the
providerOverride component — being applied for the whole run's attempt loop, not just the
first (primary) attempt: the fallback walk over agents.defaults.model.fallbacks re-resolves a
model string for each fallback entry, but the provider component of that resolution appears to
stay pinned to whatever before_model_resolve returned, regardless of the provider named in the
fallback entry itself (openai/gpt-5.5 still resolved through anvil). This would explain why
both fallback attempts hit anvil's 503 rather than the native provider.
This is consistent with, and sharpens, the "validate live (currently UNCONFIRMED)" item in
docs/adr/0001-cloud-cost-and-subscription-auth.md — the exhaustion-503 DOES trip OpenClaw's
"overloaded" failover category (that part of ADR-0001's mechanism holds), but the result of that
failover is not the native provider when a providerOverride is in play.
Scope of the defect: every turn where the plugin emits { providerOverride: "anvil", ... } —
i.e. every local-preferred-class turn (quick-edit, review, chat, long-context; the large majority of
traffic in the default classify table). It does not affect cloud-preferred turns (planning by
default): those return {} (no override at all), so there is no providerOverride to stick, and
OpenClaw's own default/native resolution runs normally.
No repo-side code fix exists. The router (anvil_serving/router/) is behaving correctly per its
own contract (503 with zero streamed local tokens, C3) — the bug is in how OpenClaw's attempt loop
re-resolves the fallback chain after a before_model_resolve override. This repo cannot patch
OpenClaw. Two operator-side mitigations (see plugins/openclaw-anvil-intent-router/README.md for
the exact config):
ANVIL_CLOUD_CLASSES— move a work-class whose local tier is known to be flaky/exhausted into the cloud-preferred set. Its turns then never touch anvil (noproviderOverrideemitted), so there is nothing for the failover walk to inherit. Zero-cost, but drops local-first routing for that class.- anvil's own opt-in metered cloud tier (ADR-0001,
configs/example-with-cloud.toml+[router].metered_cloud) — let anvil'sfallback.pyescalate to a bound cloud tier inside the sameprovider="anvil"request/response. anvil then never returns 503 for the at-risk classes, so OpenClaw's (unreliable) native failover is never invoked at all. This is the durable fix, gated by the explicit billing opt-in ADR-0001 already requires.
See also docs/adr/0005-anvil-503-native-failover-unreliable.md (the ADR record of this finding) and
docs/OPENCLAW-LIVE-VALIDATION.md (Gap 4).