Repository navigation
Tool calls fail with SchemaError when the Anthropic (native) provider returns a nested array argument as a JSON string (e.g. todowrite todos) #34652
Description
TL;DR
todowrite (and any built-in tool with an array/object parameter) fails with a hard SchemaError when the model returns a nested argument as a JSON-encoded string instead of a real array. This happens specifically with the native Anthropic provider (@ai-sdk/anthropic); OpenAI / function-calling models do not trigger it. It is intermittent and not size-gated — even an empty array [] has been observed arriving as "[]".
I'm keeping this separate from #10813 / #29142 because it adds three things those threads lack: (1) the exact failing code path, (2) instrumented proof that the widely-shared tool.execute.before workaround no longer works on current opencode (validation migrated Zod → Effect Schema and now runs before the hook), and (3) instrumented proof that no plugin hook can currently work around it.
Impact
todowrite is the primary progress-tracking tool. For anyone running a Claude model on the native Anthropic provider it fails frequently and unrecoverably (the model retries and re-fails, and the todo panel cannot be updated). There is no working user-side mitigation on current opencode.
Environment
- opencode: reproduced on
1.17.11; binary is now1.17.12and the offending validation path is unchanged (verified in the 1.17.12 compiled binary — see Root cause). - Provider: native
anthropic— nobaseURLoverride, so it uses the built-in@ai-sdk/anthropicagainstapi.anthropic.com(this is not an OpenAI-compatible gateway). - Model:
claude-opus-4-8(Opus class). OS: macOS (arm64).
Symptom (verbatim)
The todowrite tool was called with invalid arguments:
SchemaError(Expected array, got "[{\"content\":...,\"status\":...,\"priority\":...}]").
Please rewrite the input so it satisfies the expected schema.
The value after got is a stringified array (note the escaped quotes) — i.e. todos arrived as a string, not an array.
Root cause
opencode validates tool-call arguments by Effect-decoding the tool's parameters. From the compiled 1.17.12 binary (tool-execute wrapper):
// build a decoder from the tool's parameters schema, then wrap execute with it
let r = typeof s === "function" ? { ...yield* s() } : { ...s },
c = t.decodeUnknownEffect(r.parameters), // <-- Effect Schema decode of `parameters`
S = r.execute;and the error surface:
`The ${this.tool} tool was called with invalid arguments: ${this.detail}. Please rewrite the input so it satisfies the expected schema.`Claude (native tool_use) occasionally JSON-encodes the nested todos array as a string inside its tool_use input. decodeUnknownEffect then fails because the schema expects an array. This validation happens before any plugin hook can touch the arguments.
Provider specificity (why this is a distinct variant, not #29142)
- Claude via native Anthropic delivers the array pre-stringified in the tool_use input → fails.
- OpenAI / function-calling models send the whole arguments object as one JSON string that opencode parses once into
{ todos: [...] }, so the nested array is a real array → works.
So this is not "the model sent wrong keys/types" — it is the narrower case of a correctly-shaped value delivered JSON-stringified one extra level, and it is provider-specific.
Why the popular tool.execute.before workaround (from #10813) no longer works
The workaround shared in #10813 (a tool.execute.before hook that JSON.parses todos) silently stopped working. I instrumented a bare version of it (only that hook, nothing else) and confirmed with markers that for a stringified todos the hook never fires:
EXECUTE_BEFORE todosType=string : 0 # stringified calls NEVER reach execute.before
EXECUTE_BEFORE todosType=object : 1 # only native-array calls reach it
COERCED_OK : 0
Why it used to work and now doesn't: the error format in #10813 (2026-01) is Zod (code: "invalid_type", "Invalid input: expected array, received string"), whereas current opencode emits Effect SchemaError(...). Tool-arg validation migrated Zod → Effect Schema and now runs ahead of tool.execute.before, so the hook can no longer repair the args.
Proof that no plugin can currently work around this (1.17.11 / 1.17.12)
All four plugin avenues were tested with an instrumented plugin:
tool.definitionoverride of a built-in tool'sparameters/jsonSchemais not used for validation — the override'svalidateis never invoked (TOOLDEF_FIRED: 5,VALIDATE_CALLED: 0). The original Effect schema still rejects the string.tool.execute.beforeruns after validation (markers above) — too late.- Shadow / custom tool cannot replace
todowrite: the SDK exposes onlyGET /session/{id}/todo(no write endpoint), so a plugin cannot update todo state. - (Minor, worth documenting) global
~/.config/opencode/plugin/is not auto-loaded; only project.opencode/plugin/is.
Reproduction
- Configure a Claude model (Opus class) on the native
anthropicprovider as the agent. - Have it call
todowritewith atodosarray (verbose / large items raise the odds, but small ones fail too). - Intermittently:
SchemaError(Expected array, got "<stringified array>"). A single-item or even empty array can trigger it.
Relationship to #10813 and #29142
- todowrite:tool call parameter error #10813 — same end symptom, but predates the Zod → Effect migration and its accepted workaround is now stale. This report adds the current root cause, proof the workaround is dead, and proof no plugin can fix it.
- OpenAI-compatible models can call write/edit with invalid schema arguments #29142 — broader "wrong types / missing keys" across
write/edit. This is the narrower, provider-specific "correctly-shaped value delivered JSON-stringified" case.
Happy to consolidate if maintainers prefer a single tracking issue — the actionable fix below is the same either way.
Suggested fix
At tool-argument decoding, if a field whose schema expects an array/object arrives as a string that JSON.parses to a value satisfying the schema, coerce it before/within decodeUnknownEffect. Scope this strictly to tool-call argument decoding so it cannot mask unrelated type errors. (Alternatively / additionally: let plugin tool.definition parameters overrides actually drive validation for built-in tools, so provider quirks can be patched from userland.)
Happy to test a patch build.
Activity
github-actions commentedon Jun 30, 2026
This issue might be a duplicate of existing issues. Please check:
todowritetool failure withExpected array, received stringfor thetodosparameter — identical root symptomwrite/edittools with various providers, same class of problemIf your issue is specifically about the Anthropic native provider delivering pre-stringified nested values (rather than wrong key names), it may be a distinct variant worth keeping open — but please review those issues first and comment if yours is genuinely different.
edikid commentedon Jul 1, 2026
Confirmed distinct from the flagged issues, and keeping this open. I've rewritten the body with the full root-cause trace + instrumented proof. Two things that are not covered by #10813 or #29142:
tool.execute.beforeJSON.parseworkaround from todowrite:tool call parameter error #10813 no longer works. Tool-arg validation migrated Zod → Effect Schema and now runs beforetool.execute.before, so the hook never sees the stringified value. Verified with an instrumented plugin:EXECUTE_BEFORE todosType=string : 0(stringified calls never reach the hook),VALIDATE_CALLED : 0for atool.definitionparametersoverride (overrides aren't used for built-in validation).Still reproduces on
1.17.12— thedecodeUnknownEffect(r.parameters)path is unchanged in the current binary. Fix suggestion + full evidence are in the updated body.edikid commentedon Jul 1, 2026
Update: still reproduces on the latest opencode (
1.17.12) with a newer Claude model generation (claude-sonnet-5), ruling out two hypotheses.Retested today on a genuinely fresh setup:
1.17.12— confirmed the backend process started after the new binary was installed (not a stale long-running process from before the upgrade).claude-opus-4-8toclaude-sonnet-5(still native Anthropic provider, no gateway).Three consecutive
todowritecalls in the same fresh session:SchemaError(Expected array, got "[…]")SchemaErrorSame signature as the original report: intermittent, not size-gated (a single item still fails), no application-level error logged (rejected at tool-arg validation before reaching opencode's own error paths).
This rules out:
1.17.12still reproduces; thedecodeUnknownEffect(r.parameters)validation path is unchanged from1.17.11in the compiled binary.No change to the root cause / fix suggestion in the issue body — this is just fresh confirmation that the bug is current and not model- or version-specific.
edikid commentedon Jul 2, 2026
Final forensics: exhaustive client-side elimination completed — the emission is model-level, and the validation boundary is the only place this can be fixed
Since my last update I ran a staged elimination of every client-side variable on
1.17.12(Claudesonnet-5/fable-5, native Anthropic provider), with per-layer instrumentation (marker logs from plugins). Result: all six layers active simultaneously, the failure still reproduces.chat.params→options.toolStreaming=false(same mechanism opencode itself uses for github-copilot)inputredacted from replayed historyexperimental.chat.messages.transformoutputs=3rewrittentexts=12scrubbed in one requestAdditional data points:
todowritein a while usually succeeds, subsequent ones fail near-deterministically; in a long, multiply-compacted session even creates fail every time. Context sanitization (2–5) did not change this — so in-context bad samples are an amplifier (a self-imitation cascade, cf. Intermittent malformed tool calls: emitted as plain text instead of structured tool_use, then cascades via retry anthropics/claude-code#61367), not the root.todosarray arriving as a string). Combined with the model-family-wide reproduction here (opus-4-8, sonnet-5, fable-5) and GPT models never exhibiting it, the most consistent explanation is a trained-in "todos-as-string" attractor in the Claude lineage — suppressed by strong conditioning in short contexts, dominant in long/degraded ones. That is not something any client can fix.Why no plugin/user-side fix is possible (all verified on 1.17.11/1.17.12)
tool.definitionoverrides ofparameters/jsonSchemaare not used for validation (instrumented: override's validate never called).tool.execute.beforeruns after validation (instrumented: stringified calls never reach it).todowrite(SDK exposes onlyGET /session/{id}/todo, no write).experimental.chat.messages.transformworks (verified) but doesn't prevent the emission.Recommended fixes (in order of robustness)
stringarrives thatJSON.parses to a schema-conforming value, accept the parsed value. One localized change; neutralizes the model defect permanently for every tool, every Claude model.toolStreaming=falsefor@ai-sdk/anthropicmodels, but the nativeanthropicprovider path doesn't. Empirically insufficient alone (see table), but worth aligning.Happy to share the instrumentation plugins or test a patch build.
mrjw717 commentedon Jul 17, 2026
Corroborating evidence + 1.17.20 hook-ordering finding (different from 1.17.11/1.17.12)
Forensic audit of a production opencode deployment confirms this bug and adds a new data point: on opencode 1.17.20 (Jul 13 build),
tool.execute.beforeappears to fire BEFORE schema validation — contradicting the instrumentation results from 1.17.11/1.17.12 in this issue. A plugin-sideJSON.parserecovery hook may now work again.Regression timeline (from opencode.db SQLite store)
The bug was not present before Jul 7 and appeared immediately after an opencode binary reinstall on Jul 6 23:55:
todowritecallsSchemaError(Expected array)Same model (
glm-5.2), same provider (ollama-cloud): Jul 6 = 3/3 success, Jul 7 = 44/56 (10 schema failures). The model did not change; the binary did.Multi-model/multi-provider scope
This is not Anthropic-specific in our environment. The 54 failures span:
7 agent types affected:
orc-task-worker(18),plan(13),dojomojo-builder(13),build(9),dojomojo-architect(3),savant(2),orc-auditor(1).Malformed-JSON variants observed
The model does not always emit valid JSON inside the stringified array. From the 54 failing payloads:
"[{\"content\":\"...\",\"priority\":\"high\",\"status\":\"completed\"}]"—JSON.parsesucceeds directly....,priority\":\"high\"...— the opening"on the key is missing.\"content\":WS1: Delete broken symlinks...\",\"priority\"— the opening"on the value is missing.\"priority\":high,— the valuehighhas no quotes at all (both"missing).Hook ordering on 1.17.20
Extracted from the 1.17.20 compiled binary (
SessionTools.resolve, built-in tool path):The hook fires with
{args:b}(raw model args) beforeu.execute(which wraps validation viadecodeUnknownEffect). Sinceoutput.argsis the same reference asb, atool.execute.beforehook that mutatesoutput.args.todosfrom string→array changesbbefore validation sees it.This contradicts the 1.17.11/1.17.12 finding in this issue (
EXECUTE_BEFORE todosType=string : 0). Either the ordering was reverted in a version between 1.17.12 and 1.17.20, or the built-in tool path (o[u.id]) has a different ordering than the path the previous instrumentation measured.Our workaround
We implemented a
tool.execute.beforerecovery hook (open-source, dj-guardrails plugin) that:typeof args.todos === "string"JSON.parse; on failure, applies targeted regex repairs for the known todo-item schema (unquoted keys, unquoted values, unquoted enum values)output.args.todosto the recovered array on success; no-ops on failure (lets the originalSchemaErrorfire)Tested against all 4 malformed-JSON variants from our failure corpus — all recover successfully. Pending empirical verification that the hook fires pre-validation on 1.17.20 (our running opencode session cached the pre-rebuild plugin; next restart will load the hook).
Suggested fix (same as issue body)
Coerce at the tool-arg decode boundary: if a field whose schema expects an array/object arrives as a
stringthatJSON.parses to a schema-conforming value, accept the parsed value. This is the only robust fix since the model-level emission cannot be controlled by the client. Our plugin-side hook is a workaround fortodowritespecifically, but the same defect affects any tool with array/object parameters.Happy to share the recovery hook code or test a patch build.
github-actions commentedon Sep 20, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.