Summary
put_artifact rejects any content that is a JSON object string. The value is coerced
from a string into a Python dict somewhere in the MCP argument layer before Pydantic
validates it, so the str | None-typed content field then rejects it as the wrong type:
Error executing tool put_artifact: 1 validation error for put_artifactArguments
content
Input should be a valid string [type=string_type,
input_value={'started_at': '2026-09-0... {}, 'completed': False}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.13/v/string_type
Note input_type=dict — by the time validation runs the argument is no longer the string
that was sent. A string-typed field can therefore never accept JSON-object-shaped text.
The handler in src/panopticon/taskservice/mcp.py is itself correct:
async def put_artifact(
task_id: str, name: str, content: str | None = None, content_base64: str | None = None
) -> str:
if content is not None and content_base64 is None:
data = content.encode()
content.encode() is exactly right for a str. The problem is upstream — the caller's
string is parsed into a dict before it ever reaches this signature.
Reproduction
put_artifact(
task_id = "<any task id>",
name = ".babysit-ci-state.json",
content = '{"started_at": "2026-09-04T17:31:00Z", "completed": false}'
)
Fails every time; any JSON object literal reproduces it.
What was ruled out
- Not name-dependent. The
.json extension is irrelevant — the coercion is on content.
- Not a whitespace or parsing edge. Retried with a leading newline and with a trailing
newline wrapping the object; identical error both times.
- Not a general
content failure. The same tool accepted a large multi-line markdown
string in the same session moments earlier. Only object-shaped content trips it.
Untested but likely affected by the same coercion: JSON array content ([...]). A JSON
scalar (bare quoted string or number) would probably survive, coercing to str/int rather
than dict.
Impact
The babysit-ci skill specifies a cross-turn state artifact named .babysit-ci-state.json
with a documented schema (started_at, head_sha, watch_bash_id, retries, completed).
That artifact is JSON by design, so the skill's own prescribed state-passing mechanism cannot
be used as written — the agent hits this error on the first write and again on every update.
Any skill or workflow that persists structured state through put_artifact hits the same wall.
Encountered while running the github-issue workflow; CI-watch state had to go to a local
/tmp file instead, which does not survive the container and is not readable via the task's
MCP resource URI.
Workarounds
- Wrap the JSON in a markdown fence so the payload is not object-shaped at the top level, and
strip the fence on read.
- Use the REST endpoint (
PUT /tasks/{id}/artifacts/{name} with raw bytes), which bypasses
the MCP argument layer entirely.
- Base64 the JSON and send it as
content_base64 — though that field is meant for binaries.
All three are worse than just writing the string.
Suggested fix
Stop parsing content before validation, or accept str | dict | list and re-serialize
non-string input to JSON text before storing. The first is preferable: artifact content
should round-trip byte-for-byte, and a permissive type would silently reformat whatever the
caller sent (key order, indentation, False vs false).
Worth checking whether the same coercion affects other string-typed MCP tool params here —
anything that might legitimately carry JSON-shaped text.
Summary
put_artifactrejects anycontentthat is a JSON object string. The value is coercedfrom a string into a Python dict somewhere in the MCP argument layer before Pydantic
validates it, so the
str | None-typedcontentfield then rejects it as the wrong type:Note
input_type=dict— by the time validation runs the argument is no longer the stringthat was sent. A string-typed field can therefore never accept JSON-object-shaped text.
The handler in
src/panopticon/taskservice/mcp.pyis itself correct:content.encode()is exactly right for astr. The problem is upstream — the caller'sstring is parsed into a
dictbefore it ever reaches this signature.Reproduction
Fails every time; any JSON object literal reproduces it.
What was ruled out
.jsonextension is irrelevant — the coercion is oncontent.newline wrapping the object; identical error both times.
contentfailure. The same tool accepted a large multi-line markdownstring in the same session moments earlier. Only object-shaped content trips it.
Untested but likely affected by the same coercion: JSON array content (
[...]). A JSONscalar (bare quoted string or number) would probably survive, coercing to
str/intratherthan
dict.Impact
The
babysit-ciskill specifies a cross-turn state artifact named.babysit-ci-state.jsonwith a documented schema (
started_at,head_sha,watch_bash_id,retries,completed).That artifact is JSON by design, so the skill's own prescribed state-passing mechanism cannot
be used as written — the agent hits this error on the first write and again on every update.
Any skill or workflow that persists structured state through
put_artifacthits the same wall.Encountered while running the
github-issueworkflow; CI-watch state had to go to a local/tmpfile instead, which does not survive the container and is not readable via the task'sMCP resource URI.
Workarounds
strip the fence on read.
PUT /tasks/{id}/artifacts/{name}with raw bytes), which bypassesthe MCP argument layer entirely.
content_base64— though that field is meant for binaries.All three are worse than just writing the string.
Suggested fix
Stop parsing
contentbefore validation, or acceptstr | dict | listand re-serializenon-string input to JSON text before storing. The first is preferable: artifact content
should round-trip byte-for-byte, and a permissive type would silently reformat whatever the
caller sent (key order, indentation,
Falsevsfalse).Worth checking whether the same coercion affects other string-typed MCP tool params here —
anything that might legitimately carry JSON-shaped text.