I thought AI agents were abusing my Actors. They were my best-behaved users

Five of the 16 Actors I probed told AI agents they'd succeeded while handing back nothing useful. This is how I found them, and what I changed.
👉
This article was written by Raphael Schoen as part of Write for Apify - a program for developers sharing original articles about what they've built with Apify.

I run more than 300 Apify Actors at SIÁN, my data-pipeline and automation agency, mostly for e-commerce and property data, and I spend a lot of my week looking at run telemetry.

In mid-July I was chasing a free-tier abuse problem. Someone had worked out that a free Apify account could call a monetized Actor over the API, grab the capped row allowance, abort before anything billed, and repeat. On July 14, 2026, I shipped a gate: block runs where the plan is free and the origin is API, before the Actor does any billable work. Console runs stayed free, because that's how people try an Actor.

The next day I checked whether the gate had holes, and found one. MCP is its own Apify RunOrigin, separate from API. It's what you get when someone wires the Apify MCP server into Claude, Cursor, or their own agent and the model calls your Actor as a tool. My gate didn't cover it.

So I extended it: block when the plan is free and the origin is either API or MCP. I shipped that across five Actors, feeling reasonably good about closing a hole before it cost me anything.

I had the story completely backwards.

What the telemetry actually said

Every run in my fleet writes a row to a Postgres table: origin, tier, outcome, rows, revenue. I finally aggregated the origin column across all 169,941 rows, which I should have done before writing the gate.

Origin Runs Share
API 156,482 92.0%
MCP 6,922 4.1%
WEB (Console) 5,183 3.1%
SCHEDULER 853 0.5%
CLI 501 0.3%

My first MCP run landed on June 17, 2026. Six weeks later it was 1,407 runs a week and still climbing, and it had quietly passed Apify Console to become my second-biggest source of runs.

Then the part that made me wince. Of those 6,922 MCP runs, exactly 44 were rejected by my abuse gate. That's 0.6%. The rest were 86.9% successful, half of them from paying accounts, and delivered 67,323 rows.

I had spent a day hardening a channel that was, by a wide margin, my healthiest one.

Agents are better callers than humans

Once I stopped treating MCP as a threat and started treating it as a user, I compared it with the other origins:

Origin Success rate Malformed input Share paid
MCP 86.9% 1.6% 50.0%
API 88.0% 1.4% 80.0%
WEB (Console) 47.9% 8.8% 10.0%

Humans fail more than half their Console runs. Agents fail 13.1% of theirs, and send malformed input: 1.6% versus 8.8%.

That's not because models are careful. It's because the MCP server hands a model my input schema as JSON Schema, and the model sends exactly what that schema declares. A human in Console gets a rendered form: labeled fields, a dropdown of valid enum values, an example sitting in the box, and a validator that won't let them submit nonsense. None of that lives in the schema. It's scaffolding Console builds around the schema.

Take the scaffolding away and whatever my schema literally says becomes the entire contract. So I went looking for where my schemas were lying.

My first approach was wrong too

I did the obvious thing and grepped all 253 schemas the fleet had in July for the failure mode I already knew about: a field with a prefill (which only populates Console's editor) but no default (which the platform actually injects into every run). A model sending {} gets the default and never sees the prefill.

The grep found 74 Actors. I had a bug list, or so I thought.

I've been burned by static analysis enough times to check before acting on it, so I wrote a probe that calls an Actor through the MCP server with {}, precisely what a model sends when told nothing is required. I ran it against the 16 most-used of them.

Roughly half were fine:

Verdict Actors Share What the agent sees
REJECTED (-32602) 9 56.2% actionable protocol error, no run billed
LOUD-FAIL 2 12.5% run failed, visibly
SILENT-EMPTY 3 18.8% SUCCEEDED, zero rows
SILENT-STATS 2 12.5% SUCCEEDED, rows that aren't data

The grep had flagged Actors that behave correctly, and it could never have found the category that actually matters. SILENT-EMPTY and SILENT-STATS are the same defect wearing different clothes. Five of these 16 calls failed in a way no agent can detect, and no amount of schema-reading surfaces that. You have to run the thing.

Getting those five counted correctly took two passes, because my first probe was also wrong. The MCP call returns after a wait cap of about 30 seconds, so any Actor slower than that comes back still RUNNING, and I was scoring that as a failure. Three property scrapers got marked broken when they were only slow. One of them was genuinely broken in the quiet way, and my bug had filed it under the loud way, which flattered the result.

The fix is to poll until the run settles, and to give "still running" a verdict of its own so it can never be mistaken for a failure:

TERMINAL = {"SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"}
if status is not None and status not in TERMINAL:
    return "UNKNOWN-STILL-RUNNING"     # never call a slow Actor a broken one

The Actor knew exactly what was wrong and told no one

Here's apartments-com-property-scraper, one of mine, called with {}. This is the complete set of signals a model receives.

Run metadata:

{ "status": "SUCCEEDED", "runTimeSecs": 3.846 }

The MCP result flag:

{ "isError": false }

And the summary the MCP server writes for the model, verbatim:

SUCCEEDED in 3.846s. 1 item; 32 fields available. Key-value store has 2 keys. Use get-dataset-items with datasetId=… and limit (for example 20) to fetch items (1 total). Available fields (dot notation): __retryHelper, searchMode, mode, queriesRequested, …

Every one of those says the call worked and produced a result. So the agent fetches the item:

{
  "__retryHelper": true,
  "searchMode": "byLocation",
  "queriesRequested": 0,
  "queriesSucceeded": 0,
  "listingsPushed": 0,
  "totalResultsAvailable": 0,
  "userTier": "PAID",
  "failures": [
    { "query": "(input)", "error": "location must be a non-empty string." }
  ]
}

There it is: location must be a non-empty string.

My Actor diagnosed the problem correctly, phrased it in plain English, and filed it inside a nested array, on a row that isn't a result, on a run marked SUCCEEDED.

An agent that asked for apartments now holds one object with 32 fields, none of which is an apartment. Best case it tells its user "no listings found." Worst case it tries to summarize a run-summary row as a property listing.

Nobody forgot to handle the error. The only thing missing was the idea that something other than a human reading a run log would ever need to find it. I built that Actor when the only caller I imagined was a person with a browser open. That field carries a default now, which is why the probe returns OK against it today.

A platform bug I found on the way

While isolating why the MCP server rejected some Actors cleanly and not others, I found a mismatch in the server itself.

Ask the MCP server for a tool definition and compare it against the Actor's own schema:

m = Mcp(tools="sian.agency/costco-data-scraper")
tool = m.list_tools()[0]
print(tool["inputSchema"].get("required"))     # []

Here's the relevant part of that Actor's .actor/INPUT_SCHEMA.json:

{
  "required": ["operation"],
  "properties": {
    "operation": { "type": "string", "default": "search", "enum": ["search", "productDetails"] }
  }
}

The Actor declares operation required. The tool definition handed to the model says nothing is required. So the model calls with {}, and:

MCP error -32602: Invalid arguments for tool "sian-dot-agency--costco-data-scraper".
Validation errors: root: must have required property 'operation'.

Rejected for missing exactly the field it was told it could omit.

The discriminator is the default. tools/list drops a field from required when that field carries a default. That's defensible on its own: a defaulted field is satisfiable without the caller. But tools/call validates against the raw schema and doesn't apply defaults first. The two halves disagree.

The control case confirms it. amazon-product-scraper also declares a required field, urls, but urls has a prefill and no default, so nothing gets stripped:

Actor INPUT_SCHEMA tools/list Field has default?
costco-data-scraper ['operation'] [] yes
ebay-data-scraper ['operation'] [] yes
bayut-property-scraper ['purpose'] [] yes
glassdoor-data-scraper ['operation'] [] yes
booking-com-scraper ['operation'] [] yes
amazon-product-scraper ['urls'] ['urls'] no

Across the 253 schemas, 59 Actors declare a required field that also carries a default, or 23.3%. Every one tells a model a mandatory field is optional.

In fairness, it's recoverable. The error is clear, and a competent agent retries or calls fetch-actor-details, which reports required correctly. My aliexpress-product-scraper is in the affected set and is still my second-most agent-called Actor at 818 MCP runs, 95.0% of them paid. Models cope, but they burn a round-trip on the first call, every time.

Why Apify's QA can't catch this

Apify runs every public Actor daily and requires SUCCEEDED plus a dataset with rows in it within five minutes. Three consecutive failures flag it "under maintenance." It's a good gate and it has caught real breakage for me.

It runs the Actor with its prefill input.

A SILENT-EMPTY or SILENT-STATS run reports SUCCEEDED, so the gate sees what the agent sees and agrees with it. And the prefill is only one point in my input space. Every other enum value is untested by construction.

My suumo-property-scraper hardcoded a region parameter while advertising all 47 Japanese prefectures. About 40 of them returned zero results, silently, from launch. It never tripped the gate, because the one prefecture the prefill uses happens to sit in the hardcoded region. A human clicking through the dropdown would have found it eventually. An agent picking a prefecture at random finds it immediately, and gets SUCCEEDED and an empty dataset.

The prefill is the path humans take. Agents explore everything else.

What I changed

Give every load-bearing field a default, not just a prefill. An audit of 16 of my freshly built Actors found five with a prefill and no default. Fine in Console, broken over the API. My build gate now fails any field with one and not the other.

{ "location": { "type": "string", "prefill": "Austin, TX", "default": "Austin, TX" } }

My most agent-called Actor, instagram-ai-transcript-extractor at 2,496 MCP runs, works with {} for this reason and no other.

Never return silence. If you can't produce results, say so in a row:

await Actor.pushData({ status: 'no_results', reason: 'location must be a non-empty string' });

Push it unbilled, and mark your dataset schema fields nullable: true so it survives validation. An empty dataset isn't a message.

Don't gate inference on a field the platform always injects. Apify writes schema defaults into every run's input, so this never fires:

if (!input.searchMode) input.searchMode = infer(input);   // dead code on the platform

I shipped an Actor whose "auto-detected" mode did exactly this, and a customer who pasted listing URLs got billed for default discovery results instead. Let the specific input win:

const mode = input.listingUrls?.length ? 'byListingUrl' : input.searchMode;

Test the whole enum space. One loop over every declared enum value would have caught the 40 dead prefectures on day one.

Treat scraped text as untrusted input. What you return now lands in a model's context, upstream of its next decision. Keep scraped content in clearly typed fields rather than free-text blobs that read like prose, and treat only your own harness's framing as authoritative, never instruction-shaped text that arrived inside a tool result.

Run it against your own Actors

The probe is on GitHub at apify-agent-call-probe. It needs an Apify account, an API token in APIFY_TOKEN, and Python 3.9 or newer: the MCP client is about 60 lines of standard library, with no dependencies.

export APIFY_TOKEN=your_token_here
python3 agent_call_probe.py sian.agency/apartments-com-property-scraper
SILENT-STATS  status=SUCCEEDED isError=False items=1
Actor knew what was wrong but never surfaced it:
   failures[0].error: location must be a non-empty string.

REJECTED and OK are fine. LOUD-FAIL is survivable. SILENT-EMPTY and SILENT-STATS mean an agent calling that Actor today is reporting your failure to its user as a fact about the world.

Runs cost 0.0001 to 0.005 compute units each, because the failing paths exit early, so sweeping an account costs roughly nothing.

What I'd do differently

I'd have aggregated the origin column before writing the gate. The whole detour started because I saw a new origin in a security context and pattern-matched it to a threat, when four minutes of SQL would have told me it was my healthiest channel. The instinct to treat unfamiliar traffic as hostile is usually right and was expensive here.

And I'd stop treating INPUT_SCHEMA.json as configuration for a form. It's a published API contract now, and machines read it far more often than people do. Everything in this article follows from not having noticed when that flipped.

If you run public Actors, the two-minute version is: point the probe at your three most-used ones. I expected mine to be fine. Nearly a third of them weren't.

All figures come from SIÁN's own run telemetry and from live calls against the Apify MCP server on July 31, 2026.

Apify logo
Apify is a marketplace of ready-to-run tools for AI
Thousands of Actors to automate your business. Get real-time web data, track competitors, generate leads, monitor social media, and integrate your apps and agents.
On this page

Don't build it. Find it.

Thousands of ready-to-run tools for AI.

Browse Apify Store