AI agents were told my inputs were optional, then rejected for leaving them out 馃
One word in 29 config files: required . Every field it guarded already had a default. Hi! I'm Tim, a self-taught developer in Nairobi. I build small data tools that run as Apify Actors, serverless jobs that people pay for per result. 100 of them are public. This week Apify's DEV account published W

One word in 29 config files: required. Every field it guarded already had a default.
Hi! I'm Tim, a self-taught developer in Nairobi. I build small data tools that run as Apify Actors, serverless jobs that people pay for per result. 100 of them are public.
This week Apify's DEV account published What AI agents see when they call your Apify Actors, by SI脕N Agency. One finding in it sent me straight to my own input schemas.
A person runs an Actor from a form. The fields have labels, the dropdowns list the valid values, and an example is already filled in.
An AI agent connected through the Apify MCP server gets none of that. It gets the input schema as JSON Schema, and it sends what the schema says.
The article found that two parts of the MCP server read that schema differently:
So a field that is both required and has a default is listed as optional. When the model leaves it out, the call is rejected with a validation error ( -32602 ) for the very field it was told it could skip.
An agent can recover by fetching the Actor's details and trying again. It still burns a round trip on the first call, every time. The author found this in 59 of the 253 schemas they checked.
I wrote a short script that reads every.actor/input_schema.json in my portfolio. For each required field, it records whether the field has a default and whether that default equals the prefill, the example shown in the form.
Here's a typical one, from a vehicle safety ratings lookup (trimmed to the parts that matter):
{ "required": [ "year", "make", "model" ], "properties": { "year": { "type": "integer", "prefill": 2022, "default": 2022 }, "make": { "type": "string", "prefill": "Honda", "default": "Honda" }, "model": { "type": "string", "prefill": "Accord", "default": "Accord" } } } Enter fullscreen mode Exit fullscreen mode All three fields are required, and all three have a default. In the form, nothing looks wrong, because the form is already filled in. To an agent, all three are listed as optional, and a call without them fails.
Apify runs every public Actor once a day with its prefilled input, and flags it if that run fails three days in a row.
All 32 defaults were identical to the prefill. So the daily test always sent every required field, and it always passed. None of these Actors was ever flagged for it.
The test walks the path a person takes. An agent sending {} takes a different one, and nothing was walking it.
The article comes with a probe that calls each Actor through the MCP server with {}. I didn't run it on mine. My own runs show up in my Actors' analytics as a user, and that analytics page is the only signal I have about real customers, so I debug locally and keep platform runs to a minimum.
That left the static half: what the schemas say. The article is clear that this half can't find everything. An Actor can report SUCCEEDED with nothing useful in it, and only running it shows that. So after the schemas, I read through the code paths of my best-earning Actor by hand, looking for that kind of silent result. More on that in fix 3.
Apify writes schema defaults into every run's input. A required field with a default is filled in whether the caller sends it or not, so the required flag adds nothing to the run. All it does is mislead the agent.
// Drop `required` only when every required field already has a default. const noDefault = schema. required. filter (( k ) => schema. properties [ k ]. default === undefined ); if ( noDefault. length ) throw new Error ( `no default for ${ noDefault. join ( ', ' )} ` ); schema. required = []; Enter fullscreen mode Exit fullscreen mode Nothing else in any schema changed, and the script checked that too. A person in the form sees exactly the same thing as before.
A court opinion search ( query ), a travel per diem lookup ( locations ) and a package health check ( packages ) each had a required field with no default.
These were honest: with no default, nothing gets dropped from the tool listing, so an agent is told to send the field. But their code also threw on empty input:
if (! query ) { throw new Error ( ' query is required. ' ); } Enter fullscreen mode Exit fullscreen mode I wanted every one of my Actors to answer {} the same way: with the example from the form, not an error. Each of the three got a default equal to its prefill, and the code now falls back to that example:
// An empty call (an AI agent calling with {}, or an empty API call) searches the input form's example instead of failing. const query = String ( queryInput?? '' ). trim () || ' antitrust '; if (! String ( queryInput?? '' ). trim ()) log. info ( ' No query given, so searching the example "antitrust". ' ); Enter fullscreen mode Exit fullscreen mode In the two that take a list, a value that isn't a list still fails with a clear message. Only "nothing at all" falls back to the example.
My best-earning Actor audits small business websites and scores each business as a lead for web designers. It has a filter: only keep businesses that score above a minimum.
Set that minimum high enough and every business gets filtered out. The run used to end SUCCEEDED, with zero rows. An agent reading that would tell its user "no leads found", as a fact about the world. The real answer was "your filter was stricter than every business it checked".
And an empty call ( {} ) used to fail outright with "Nothing to audit", even though the schema told an agent nothing was required. Now it audits the three example businesses from the form.
For the empty case, a run that saves nothing now saves one row explaining why, and doesn't charge for it:
// Never end with an empty dataset: say why nothing was saved, in one uncharged row. if ( stats. pushed === 0 ) { const note = emptyRunNote ( stats, counters, options, search, categoryTitle ); await Actor. pushData ( note ); } Enter fullscreen mode Exit fullscreen mode Here's the row it writes when the filter removes all 5 businesses it audited:
{ "name": "No businesses saved in this run (not charged)", "websiteStatus": null, "opportunityScore": null, "note": "All 5 businesses audited scored below 100, so none were saved (filtered businesses aren't charged). Lower minScore or turn off onlyOpportunities to see them.", "processed": 5, "filteredOut": 5 } Enter fullscreen mode Exit fullscreen mode The row says what happened and what to change. A person can read that. So can a model.
29 Actors meant 29 builds. I didn't want a loop that pushes everything and checks nothing, so each Actor went through the same steps, and the script stopped at the first problem (trimmed):
before = $( node live-build.js " $a " ) # the build to roll back to git commit -q -m " $msg " && git push -q origin HEAD out = $( timeout 480 apify push 2>&1 ) echo " $out " | grep -q "Build: SUCCEEDED" || { echo "STOP $a "; exit 1; } verify = $( node live-build.js " $a " --verify ) # live schema: required is [] and matches local echo "OK $a | before $before | after $verify " Enter fullscreen mode Exit fullscreen mode The --verify step reads the input schema back from the new live build, not from my disk. All 29 passed, and the previous build of every Actor is saved in case one needs rolling back.
If you publish Actors, this one-liner lists the fields in a schema that are both required and defaulted:
node -e "const s=require('./.actor/input_schema.json');console.log((s.required||[]).filter(k=>s.properties[k]?.default!==undefined))" Enter fullscreen mode Exit fullscreen mode An empty list means an agent gets an honest contract. Anything else is a field it was told it could skip, and then got rejected for skipping.