DEV Community

Kinuthia Matata for Apify

Posted on

An Apify Actor an AI agent can actually call, and how little it took to get there

A real collection Actor that already worked, made callable by AI agents with one wrapper script and a handful of small, specific fixes.

What the Actor is, and why it’s worth calling

A few years ago I ran a real estate scraping pipeline that worked badly. About 63% success against one site, 8% against another, and a code path that shelled out to xdotool to literally hold the spacebar through an X11 session, auto-solving a “press and hold” challenge. The standard playbook, headless browsers, stealth plugins, residential proxies, synthetic mouse movement, was solving the wrong problem. Apify’s own read on where this is headed frames it as a permanent arms race, and a bypass that worked last week can fail today.

I opted out of the fingerprint-evasion fight. The Actor I’m writing about here, germane_binoculars/zillow-leads-property-data, collects live Zillow listings through a real browser extension living inside an actual human session, not a simulated one. The extension’s content script calls fetch() on the page’s own origin, so the request carries the tab’s real cookies, real TLS fingerprint, and navigator.webdriver === false, with nothing emulated. The fingerprint problem disappears; only the pacing problem remains, and a Box-Muller jittered delay loop handles that.

The Actor itself does no scraping. It is a thin buyer-facing layer over Apify’s Key-Value Store, brokering orders to a FastAPI “runner” on my machine that owns a durable SQLite cache and talks to the extension. It’s billed pay-per-event, a flat actor-start plus per row at the depth received, and streams rows into the buyer’s dataset while an order is still being fulfilled. It returns Zillow listings enriched with agent/broker contact, full price-history, 20-year tax history, foreclosure flags, and schools, at a depth no other Zillow Actor I’ve found reaches.

I had it working as an Actor a human calls from the Apify Console: fill in a form, hit Start, wait. The question this piece actually answers is what it took to make that same Actor something a coding agent can call on its own, mid-task.

Agents call tools, not forms

I do most of my own work with an agent in the loop. My normal flow is a Claude Code, OpenCode, or ZCode session, iterating on code, and I kept wanting to say something ordinary to it: “grab me live comps for this bounding box,” or “what’s listed in this neighborhood right now.” Each time, that meant tab-switching to the Console, filling a form, copying a run ID, pasting rows back into the conversation. The data was right there behind a clean API. The friction was the human in the loop, me, mediating between two things that could have talked to each other directly.

That’s the use case Apify’s MCP server, Apify’s implementation of Anthropic’s Model Context Protocol, exists for, and the reason it’s the subject of this piece. The server exposes your Actors as tools to AI clients, Claude, Cursor, Windsurf, ZCode, OpenCode, so an agent can reach for one on its own, mid-task, the way it calls any other tool. An Actor that scrapes a site or extracts structured data becomes something an agent calls without a manual trigger.

I’d assumed exposing an Actor over the MCP server meant publishing it to the Apify Store first. It doesn’t. The hosted remote endpoint (mcp.apify.com) is Store-discovery-centric, but the local stdio path, the @apify/actors-mcp-server npm package, authenticates with your own API token and exposes any Actor that token can run, including an unpublished isPublic: false one, confirmed live against my own account. You can wire an agent up to an Actor you’re still developing, before anyone else sees it.

Wiring it up

The mechanism is a small wrapper script that launches the MCP server as a stdio process, then an entry in each client’s MCP config pointing at that wrapper.

#!/usr/bin/env bash
# .zcode/apify-mcp.sh: exposes ONLY this project's actor as an MCP tool
# (the server normally surfaces Store-discovery tools too; --actors/--tools
# filter it down). Reads the token from ~/.apify_token so a token rotation
# never needs a config edit.
set -euo pipefail

TOKEN_FILE="${HOME}/.apify_token"
if [[ ! -s "$TOKEN_FILE" ]]; then
  echo "ERROR: $TOKEN_FILE missing or empty; create it with your Apify API token." >&2
  exit 1
fi
export APIFY_TOKEN="$(cat "$TOKEN_FILE" | tr -d '[:space:]')"

# npx shells out to `node` via PATH to run npm's own CLI scripts, so the
# Node 22 binary that launches it must also be first on PATH, otherwise
# npx finds an older system Node and npm's CLI dies ("Cannot find module 'node:path'").
export PATH="${HOME}/.nvm/versions/node/v22.23.2/bin:${PATH}"

exec "${HOME}/.nvm/versions/node/v22.23.2/bin/npx" -y @apify/actors-mcp-server \
  --actors germane_binoculars/zillow-leads-property-data \
  --tools germane_binoculars/zillow-leads-property-data
Enter fullscreen mode Exit fullscreen mode

That wrapper gets wired into each client’s config. Here’s the wrinkle the snippet doesn’t show: the three clients I use don’t agree on a schema. ZCode takes command as a string plus an env object. OpenCode takes command as an array plus an environment object. Claude Code takes command as a string. They are not interchangeable. I lost forty minutes copying a working ZCode entry into OpenCode before I read the error and realized the field names differed.

ZCode (.zcode/config.json), where command is a string:

{
  "mcp": {
    "servers": {
      "apify-zillow": {
        "enabled": true,
        "command": "bash",
        "args": [".zcode/apify-mcp.sh"],
        "type": "stdio"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

OpenCode (.opencode/opencode.json), where command is an array and the env object is named environment:

{
  "mcp": {
    "apify-zillow": {
      "type": "local",
      "command": ["bash", ".zcode/apify-mcp.sh"],
      "enabled": true
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Once wired, the server exposes five tools: the Actor call itself (germane_binoculars--zillow-leads-property-data), plus get-actor-run, get-dataset-items, get-key-value-store-record, and abort-actor-run. That utility set turned out to matter more than I expected, because the agent needs all of them to do its job, and I’ll come back to why.

Making the input schema agent-friendly

This is the part where the real work lived: structuring the input schema so an AI agent can call it reliably, without ambiguity, without ever opening the README.

The thing to internalize first: the MCP tool schema is auto-derived from the Actor’s input_schema.json. Every field title, every description, every enum, every default you wrote comes through verbatim into the tool schema the agent sees. So the agent only knows what the schema tells it. There’s no separate “agent documentation” layer to fall back on. If a field description is vague or absent, the agent is guessing.

That observation drove two kinds of fixes, one structural and one a plain bug.

The structural one was a budget documented in the wrong place to catch it by reading. The apify-mcp-server GitHub repo’s README does state it, under “Limitations”: descriptions get truncated to 500 characters (MAX_DESCRIPTION_LENGTH). What it doesn’t say is that the limit applies to the rendered field, after the server appends its own suffixes, \nPossible values: and \nExample values:, to enum’d fields, another 62 to 81 characters you have to leave room for. Four of my descriptions cleared 500 once that overhead was counted: mode (585), bounds (555), depth (513), and timeoutSecs (599). I found this by measuring the actual rendered output, not by reading the README closely enough first. I sized against that rendered length and got them all under the limit: mode to 410, bounds to 320, depth to 333, timeoutSecs to 377.

That exercise forced the descriptions to do their actual job. There’s no room to be vague when you have 320 characters to explain that bounds is a bounding box where north and east must each be larger than south and west, that a swapped box is rejected immediately, and that bounds wins over metro if both are set. Brevity made the field more legible, not less. The constraint did the editing.

The bug was worse because it was invisible until you looked at what the agent actually saw. My push_actor.py script only propagated the Actor’s title and description at creation time. Every re-push afterward used the update path, which never set them. So the live Actor on Apify had description=None, and the MCP tool rendered it as a blank line: "Actor description: " with nothing after it. An agent scanning its tool list to decide what’s relevant had literally nothing to judge this Actor by. The fix was a one-line patch to read .actor/actor.json and patch the title and description onto the live Actor on every push (commit dca9198, same commit as the description trimming). I had to build the tool and look at what it rendered before I noticed my own Actor had been describing itself to every caller as nothing.


The Actor’s page in the Apify Console, showing the title and description live, plus a real successful run: 11 results in 9s for $0.001

There’s a third design choice worth naming, because it’s about what an agent should not see. The run produces a few non-listing records: an ORDER_SUMMARY (what actually applied to this order, since the raw Input tab shows every schema field regardless of mode), a DEDUP_UPDATE (the zpids and MLS IDs, the Multiple Listing Service identifiers, to feed back on a repeat order), and occasionally a STATUS record for a zero-row result with an explanation. All three live in the run’s own key-value store, not the dataset. The dataset is rows of listings and only rows of listings. An agent iterating get-dataset-items never has to filter bookkeeping out of the results. The bookkeeping is separately fetchable via get-key-value-store-record, or visible to a human in the Console’s Key-value store tab. Keeping the dataset clean was a decision made for the agent’s benefit, and it’s the kind of thing that doesn’t show up in any schema until you think about the read path.

A real agent calling it

Here’s what happened when I actually called the tool, just now, from the agent session I’m writing this in. Catalog mode, a small minimum, cache-served:

mode: catalog
minListings: 5
minEnriched: 5
Enter fullscreen mode Exit fullscreen mode

The tool returned:

status: SUCCEEDED in 9.745s. 5 items; 236 fields available.
Key-value store has 3 keys.
runId: XWCu2w13hEJJFanE5
datasetId: jsRfSmVVna0Md9LTk
nextStep: Use get-dataset-items with datasetId=jsRfSmVVna0Md9LTk
and limit (for example 20) to fetch items (5 total).
Enter fullscreen mode Exit fullscreen mode

Then, following nextStep, a get-dataset-items call with fields projected down to a handful of columns returned a real row:

zpid: 3451218
address_street: 333 Beau Dr
address_city: Des Plaines
address_state: IL
price: 439000
bedrooms: 3
bathrooms: 3
agent_name: Sam Romano
agent_phone: 847-567-XXXX
Enter fullscreen mode Exit fullscreen mode

That is a genuine listing with a genuine phone number, fetched live through the MCP tool path by an agent. The fields parameter is what kept the response small; the full row carries 236 fields including full price and tax history, foreclosure detail, schools, and the resoFacts long tail (heating, cooling, basement, construction). Projecting down is the right move for a conversational fetch, then a follow-up get-dataset-items call with no fields filter pulls the whole record.

The pattern isn’t specific to one client or model. Here’s the same tool, called from a separate real session, this time from OpenCode running DeepSeek V4 Flash, fetching three catalog listings with agent contact intact (phone numbers blacked out here, not in the original):


OpenCode (DeepSeek V4 Flash) calling the same Actor tool and get-dataset-items, returning three real enriched listings with agent name, phone, and broker

The catalog path is instant because it serves from cache, and a custom_search order does too if the box you asked for happens to already be covered. I confirmed this by firing a custom_search + enriched order against a bounding box this project had already tested against repeatedly; it came back SUCCEEDED in under 8 seconds, and the extension never even saw a request, because the runner’s own cache already held everything the order needed. A live-collect order against genuinely fresh ground is different: it takes real wall-clock minutes, because enrichment (agent contact, price/tax history) is paced to avoid tripping Zillow’s bot defenses, and a single tool call won’t block long enough to see it through. The interaction model the MCP server imposes for that case is fire, poll, fetch:

  1. Fire. Call the Actor tool with your input and a generous timeoutSecs. The tool blocks up to 45 seconds (its own waitSecs cap, separate from timeoutSecs), then if the run is still going it returns a status line plus a nextStep telling you exactly how to poll. This is not an error. The run is healthy and collecting.

  2. Poll. Follow nextStep by calling get-actor-run with the runId and waitSecs: 30, repeatedly, until the status reads SUCCEEDED (or TIMED-OUT with partial results).

  3. Fetch. Once terminal, call get-dataset-items with the datasetId from the response to retrieve the actual rows.

I fired one of these for real, against a fresh box around Pittsburgh, custom_search + enriched, minListings: 10. The first poll, 31 seconds in:

status: RUNNING for 31s. In progress. 1 result so far.
runId: h5qldziGcfY8Zpw8Z
nextStep: Use get-actor-run with runId=h5qldziGcfY8Zpw8Z and
waitSecs=30 to poll for completion.
Enter fullscreen mode Exit fullscreen mode

I kept following nextStep, and what happened underneath it was more interesting than a smooth climb. On the collection side, the extension found and fully enriched all 10 listings within about 90 seconds, zero bot-defense challenges along the way. But the Actor’s own dataset count sat at 1 for the next four minutes anyway, because the runner buffers collected rows and ships them to the Actor in batches rather than one at a time, and the batch holding the other nine hadn’t gone out yet. The run finally returned SUCCEEDED at 335 seconds: 10 items, 246 fields available. From where the agent sits, polling nextStep exactly as told, “RUNNING, 1 result” for four straight minutes and then done looks identical whether the bottleneck is Zillow, the extension, or the buffering in between. It isn’t a bug, but it’s a real thing to know before you assume an order is stuck: check the collection layer’s own state before assuming the agent-facing side is broken.

This is why the server exposes all five tools and not just the Actor call. An agent that can fire but not poll can’t handle a long-running order, and the nextStep field is doing the real work: it’s the affordance that tells the agent what to call next without anyone scripting the sequence by hand.

There’s a second, now-historical timing bug in this same path worth being honest about, because I hit it live and it took a while to find. A real custom_search + enriched order was timing out at exactly 300 seconds and returning nothing, even though I could see data collecting. There were two independent timeouts, and the smaller one kept winning. The Apify platform run timeout, defaultRunOptions.timeoutSecs in actor.json, defaulted to 300s because nothing in my config overrode Apify’s platform default. The Actor’s internal deadline, inp.timeoutSecs in the code, was set to 360s for tests or 7200s by default. The platform killed the run at 300s before the Actor’s own 360s budget could elapse. Confirmed in the run metadata: the two failed runs lasted exactly 300.0 seconds despite timeoutSecs: 360 in the input. The fix was adding defaultRunOptions: {timeoutSecs: 7200} to actor.json and making push_actor.py propagate default_run_timeout_secs on every push. It had never been propagated before, the same class of bug as the description one. After the fix, the same order lived 364 seconds, returned SUCCEEDED, and delivered four enriched rows with broker contact.

The reason this matters for agents specifically: an agent building input from the schema has no tacit memory of this. It doesn’t know that an enriched order(greater than 100) could take anywhere from 5 to 30 real minutes. It doesn’t know that a quick smoke test should set minListings to 10 or 50, not the 1000 default. Left to the schema alone, an agent is more likely to hit a long collection than a human operator was. So I added a footgun warning: when an enriched order comes in above 100 rows, the Actor writes a status message immediately, before much collection time or cost has elapsed, telling the caller that this can take a while, and to cancel now if they meant a quick test. Not a hard refusal; a large enriched order is the Actor’s actual product. Just an early, actionable heads-up, in the place an agent reads: the status message it sees on the first poll.

What this doesn’t solve, and what I’d do differently

A few honest limits, because overselling this would be the worst kind of inaccuracy in a piece about verifiable claims.

Local stdio isn’t the hosted endpoint. Everything above works because the MCP server runs as a local process authenticating with my token. The hosted remote path on mcp.apify.com is Store-discovery-centric and I have not verified it works against an unpublished Actor. Once the Actor is Store-published, the remote path opens up and none of the local-wrapper machinery is needed; until then, this is a local-dev setup, fine for an agent running on my machine, not a turnkey “any agent anywhere can call this.”

The collection layer still needs a real browser somewhere. This architecture is “localized” by necessity. Something has to keep a real tab open with a real session for live collection to work. The catalog mode a casual agent call hits is served from cache and needs nothing live. But a custom_search order against uncovered ground genuinely waits on a paced browser extension on my machine. That’s a real operational dependency a pure-cloud Actor doesn’t have, traded against not needing a proxy budget at all.

Closing

The thing I built wasn’t a new scraper. It was a collection Actor I already had, made callable by an agent through the surface Apify already provides, plus a set of small, specific fixes to make that call actually reliable. None of the fixes were in the MCP wiring itself, which is a wrapper script and a config entry. They were things you only see by looking at what the agent sees: a documented 500-character description budget that actually shrinks once you count what the server appends after it, an Actor describing itself as blank because a push script skipped the update path, a platform timeout silently winning over your own, and an order that finishes collecting minutes before the agent’s own poll ever shows it, because the runner ships results in batches. Each stayed invisible until I built the tool and looked at the rendered schema, the failed run, or the frozen row count. Build the integration, then go look at what the agent actually receives; that gap is where the real work is.

References

Top comments (0)