I made my Apify Actor an AI agent tool, then read every byte it sent back
원문 본문
출처 · dev.toI have about thirty Actors on the Apify Store. Making one of them available to an AI agent through the Apify MCP server took me two minutes: add ?actors=lergassy/jobs-api to the server URL and the Actor shows up as a tool. That part is a footnote.
The useful part was what came back. I spent an afternoon calling my own Actor the way an agent calls it — raw JSON-RPC over the wire, no client in between — and logging every response. Four things surprised me, and three of them changed how I write input schemas.
The Actor here is Jobs API: job listings from Indeed, LinkedIn and company career boards in one schema. Nothing about what follows is specific to jobs, though. If your Actor has more than three inputs, the same things will happen to you.
Talking to the server without a client
Every walkthrough I found used Claude Desktop or Cursor. I wanted the traffic, not a chat transcript, so I used curl. The Apify MCP server speaks streamable HTTP: you POST JSON-RPC, you get back server-sent events.
import json, subprocess, pathlib HERE = pathlib.Path(__file__).parent TOKEN = (HERE / "tok").read_text().strip() URL = "https://mcp.apify.com/?actors=lergassy/jobs-api" def post(body, sid=None): cmd = ["curl", "-s", "--max-time", "300", "-D", str(HERE / "h.txt"), "-X", "POST", URL, "-H", f"Authorization: Bearer {TOKEN}", "-H", "Content-Type: application/json", "-H", "Accept: application/json, text/event-stream"] if sid: cmd += ["-H", f"Mcp-Session-Id: {sid}"] cmd += ["-d", json.dumps(body)] out = subprocess.run(cmd, capture_output=True, text=True).stdout return [json.loads(l[6:]) for l in out.splitlines() if l.startswith("data: ")] def session(): post({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "curl-client", "version": "1.0"}}}) sid = next(l.split(":", 1)[1].strip() for l in (HERE / "h.txt").read_text().splitlines() if l.lower().startswith("mcp-session-id")) post({"jsonrpc": "2.0", "method": "notifications/initialized"}, sid) return sid Two things to get right or nothing works. The Accept header has to name both application/json and text/event-stream — the server rejects the request otherwise. And the session id comes back in a response header, not in the body, which is why I dump headers to a file and read them back.
With a session open, tools/list shows what the agent gets:
5 tools - get-actor-run - get-dataset-items - get-key-value-store-record - abort-actor-run - lergassy--jobs-api My Actor is one tool. The other four are the plumbing around it: start a run, poll it, read the dataset, kill it. That shape matters later.
The schema the agent reads is not the schema I wrote
My input_schema.json has 23 properties. The tool definition the agent receives has 24. The server adds one of its own, and it is the single most important field in the whole exchange — I will come back to it.
The rest is my schema, rewritten. Here is one property as I wrote it, and as the agent sees it:
"keywords": { "title": "🔎 Job titles or keywords", "description": "One search per line: <code>python developer</code>, <code>registered nurse</code>, <code>marketing manager</code>. Boolean syntax the boards support works too (<code>\"data engineer\" -senior</code> on Indeed).\nExample values: [\"python developer\"]", "type": "array", "prefill": ["python developer"], "examples": ["python developer"] } Three observations, all of which cost me something.
My emoji and my HTML went straight through. The 🔎 in the title and the <code> tags in the description were written for the Apify Console input form, where they render. In a tool definition they are tokens an agent pays for and markup it has to ignore. Nobody strips them. Across the whole tool my schema is 9,135 characters, and a slice of that is decoration for a form the agent will never see.
prefill gets promoted into the description. The server appends Example values: ["python developer"] to the text. That is a genuinely good move — it converts a Console nicety into an instruction — but it means the prefill field is now documentation. I had a couple of Actors where prefill was a throwaway placeholder. Those placeholders are now the example the model imitates.
required is empty. That is my fault, not the server's. My schema requires nothing, so the tool definition tells the agent that a call with zero arguments is valid. It is not: the Actor has no useful default search. An agent that believes the schema will produce an empty run, and the run will succeed while doing so.
Which is exactly what happened next.
A green run that returned nothing
My first real call, with arguments I would have called obviously correct:
{ "keywords": ["python developer"], "location": "Berlin", "sources": ["indeed"], "maxJobsPerQuery": 10, "maxItems": 10, "includeDescription": false } The response:
SUCCEEDED in 3.297s. Dataset item count reads 0 — counts can lag right after a run finishes. Key-value store has 1 key. Fetch get-dataset-items with datasetId=paykg466SehjPTgFI and limit (for example 20) before concluding the run produced no output. I fetched them. "items": [], "itemCount": 0. Zero jobs, status SUCCEEDED, exit code 0.
The cause is embarrassing once you see it. country defaults to us, and I did not pass it. So the Actor searched Indeed US for jobs in Berlin and correctly found none. Same call with "country": "de":
SUCCEEDED in 7.874s. 10 items; 44 fields available. Nothing was broken. The schema was: two fields have to agree with each other, and no part of the tool definition says so. A human filling in the Console form sees a country dropdown sitting next to a location box and picks the matching one. An agent reads two independent properties, one with a default, and has no reason to touch the one it did not need.
This is the difference between an Actor that works and an Actor that is agent-usable, and it is not a code change. The fix goes in the prose:
-
locationnow says, in its description, that it must be consistent withcountry, and names what happens when it is not — an empty result, not an error. -
countryno longer silently defaults tousin the description text. The default stays, because breaking existing users over this would be worse, but the description states it in the first sentence.
The thing I would do differently from the start: write field descriptions for a reader who cannot see the other fields. A form is a layout. A tool definition is a flat list.
The 45-second ceiling nobody mentions
Here is the property the server adds to every Actor tool:
"waitSecs": { "type": "integer", "minimum": 0, "maximum": 45, "default": 30, "description": "Max seconds (0–45, default 30) to cap the wait for the Actor run to reach terminal state..." } Forty-five seconds, hard maximum. A tool call cannot block longer than that. My small Berlin run finished in 7.9 seconds and fit comfortably. A realistic one does not. Three keywords, two sources, 100 jobs per query, full descriptions:
RUNNING for 5s. In progress. Use get-actor-run with runId=jXPenvUnx5V3d5oHg and waitSecs=30 to poll for completion. Then, polling:
RUNNING for 43s. In progress. 296 results so far. RUNNING for 74s. In progress. 485 results so far. SUCCEEDED in 77.813s. 485 items; 51 fields available. Seventy-eight seconds. Three round trips. Every scraper I own that does anything substantial runs longer than 45 seconds, which means the normal path for an agent is not call tool, get data — it is start, poll, poll, fetch.
The server handles this better than I expected. Each response ends with a nextStep line naming the exact tool and the exact identifier to use next. That is why get-actor-run and get-dataset-items are in the tool list: the polling loop is not something the agent has to invent.
What it means for me as an Actor author:
- Emit partial results as you go. The
296 results so farline is only there because my Actor pushes to the dataset during the run instead of at the end. An Actor that buffers everything and writes once at the finish shows0 results so farfor 78 seconds, and an agent may well give up on it. - Keep a cheap mode. A call that can finish inside 45 seconds — smaller caps, descriptions off — is worth having, because single-shot beats a poll loop every time.
- Fail loudly, not emptily. An agent reads SUCCEEDED plus zero items as an answer about the world: there are no Python jobs in Berlin. That is worse than an error.
116,483 characters versus 2,579
The last measurement is the one I would put in front of anyone pricing an agent workflow.
I fetched my 10 Berlin jobs with no field selection. The response was 116,483 characters — roughly 29,000 tokens, for ten job listings, because the dataset has 44 fields per row and one of them is a full job description.
Then the same ten rows with the fields an agent actually needs to answer what Python jobs are open in Berlin:
post({"jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": {"name": "get-dataset-items", "arguments": {"datasetId": "X7bLTUTlRMIyunaI5", "limit": 10, "fields": "title,company,location,salaryMin,salaryMax,applyUrl"}}}, sid) 2,579 characters. Forty-five times smaller, same answer.
Note the type: fields is a comma-separated string, not an array. I passed a list first, the way my own schema takes arrays, and got:
MCP error -32602: Invalid arguments for tool "get-dataset-items". Validation errors: /fields: must be string. A clean, recoverable error — the agent is told the type and which path failed. That is the standard my own error messages should meet and mostly do not.
The Actor-author lesson is about field order, not about fields. The server lists available fields back to the agent in the order the dataset defines them, and a model asked to choose will lean on the first ones it reads. My wide rows now start with title, company, location, salary and apply link, and the bulky text sits at the end. It costs nothing and it moves the default behaviour in the right direction.
What I changed
Four edits, all in the input schema, none in the scraping code:
-
locationnow states the cross-field constraint in words: it has to agree withcountry, and a mismatch returns zero rows rather than an error. The agent has no layout to infer that from. -
countryleads with the fact that it defaults tous, instead of burying it under a list of sixty country codes. -
keywordssays it is required unless career-site boards or start URLs are filled in. I did not add it torequired, because runs driven by start URLs alone are legitimate and marking it required would break them — but "call with no arguments" no longer reads as sensible. -
prefillvalues are real, correct examples everywhere, now that I know the server promotes them into the description the model reads.
One thing I did not have to change, and only noticed because of this exercise: the Actor pushes rows to the dataset as it goes rather than at the end. That is why the polling responses said 296 results so far instead of 0. An Actor that buffers everything and writes once at the finish looks dead for seventy-eight seconds, and an agent has no way to tell that apart from a stuck run. If yours buffers, that is the highest-value fix on this list.
None of this makes an Actor smarter. It makes it legible to a caller that can only read the schema, cannot see the Console, will not notice that two dropdowns belong together, and pays by the token for everything you hand back.
If you want to look at the tool your own Actor exposes, it is one request. Point the URL at https://mcp.apify.com/?actors=<username>/<actor>, run tools/list, and read what comes back as if you had never seen your own input form. I did, and I found four things to fix in an afternoon.
For further actions, you may consider blocking this person and/or reporting abuse
이 글은 dev.to 의 원문을 정제해 보여드립니다. 저작권은 원저작자에게 있습니다.
전체 내용이 궁금하다면
dev.to 원문에서 이어 읽기





