{"slug": "i-made-my-apify-actor-an-ai-agent-tool-then-read-every-byte-it-sent-back", "title": "I made my Apify Actor an AI agent tool, then read every byte it sent back", "summary": "A developer with roughly thirty Actors on the Apify Store documented what happens when an Apify Actor is exposed to an AI agent through the Apify MCP server, finding that the server rewrites the Actor's input schema before handing it to the model. Testing the Jobs API Actor via raw JSON-RPC over streamable HTTP with curl, the developer found that emoji and HTML markup written for the Apify Console input form pass through unchanged into the tool definition, and that the server adds an extra property to the schema's 23 fields. The developer said the setup itself took two minutes and was a footnote compared with the schema behavior observed in the responses.", "body_md": "I 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.\n\nThe 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.\n\nThe Actor here is [Jobs API](https://apify.com/lergassy/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.\n\nEvery 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.\n\n``` python\nimport json, subprocess, pathlib\n\nHERE = pathlib.Path(__file__).parent\nTOKEN = (HERE / \"tok\").read_text().strip()\nURL = \"https://mcp.apify.com/?actors=lergassy/jobs-api\"\n\ndef post(body, sid=None):\n    cmd = [\"curl\", \"-s\", \"--max-time\", \"300\", \"-D\", str(HERE / \"h.txt\"),\n           \"-X\", \"POST\", URL,\n           \"-H\", f\"Authorization: Bearer {TOKEN}\",\n           \"-H\", \"Content-Type: application/json\",\n           \"-H\", \"Accept: application/json, text/event-stream\"]\n    if sid:\n        cmd += [\"-H\", f\"Mcp-Session-Id: {sid}\"]\n    cmd += [\"-d\", json.dumps(body)]\n    out = subprocess.run(cmd, capture_output=True, text=True).stdout\n    return [json.loads(l[6:]) for l in out.splitlines() if l.startswith(\"data: \")]\n\ndef session():\n    post({\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\",\n          \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {},\n                     \"clientInfo\": {\"name\": \"curl-client\", \"version\": \"1.0\"}}})\n    sid = next(l.split(\":\", 1)[1].strip()\n               for l in (HERE / \"h.txt\").read_text().splitlines()\n               if l.lower().startswith(\"mcp-session-id\"))\n    post({\"jsonrpc\": \"2.0\", \"method\": \"notifications/initialized\"}, sid)\n    return sid\n```\n\nTwo 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.\n\nWith a session open, `tools/list` shows what the agent gets:\n\n```\n5 tools\n- get-actor-run\n- get-dataset-items\n- get-key-value-store-record\n- abort-actor-run\n- lergassy--jobs-api\n```\n\nMy 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.\n\nMy `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.\n\nThe rest is my schema, rewritten. Here is one property as I wrote it, and as the agent sees it:\n\n```\n\"keywords\": {\n  \"title\": \"🔎 Job titles or keywords\",\n  \"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\\\"]\",\n  \"type\": \"array\",\n  \"prefill\": [\"python developer\"],\n  \"examples\": [\"python developer\"]\n}\n```\n\nThree observations, all of which cost me something.\n\n**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.\n\n**`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.\n\n**`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.\n\nWhich is exactly what happened next.\n\nMy first real call, with arguments I would have called obviously correct:\n\n```\n{\n  \"keywords\": [\"python developer\"],\n  \"location\": \"Berlin\",\n  \"sources\": [\"indeed\"],\n  \"maxJobsPerQuery\": 10,\n  \"maxItems\": 10,\n  \"includeDescription\": false\n}\n```\n\nThe response:\n\n```\nSUCCEEDED in 3.297s. Dataset item count reads 0 — counts can lag right after a run\nfinishes. Key-value store has 1 key.\nFetch get-dataset-items with datasetId=paykg466SehjPTgFI and limit (for example 20)\nbefore concluding the run produced no output.\n```\n\nI fetched them. `\"items\": [], \"itemCount\": 0`. Zero jobs, status SUCCEEDED, exit code 0.\n\nThe 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\"`:\n\n```\nSUCCEEDED in 7.874s. 10 items; 44 fields available.\n```\n\nNothing 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.\n\nThis 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:\n\n`location` now says, in its description, that it must be consistent with `country`, and names what happens when it is not — an empty result, not an error.`us` in the description text. The default stays, because breaking existing users over this would be worse, but the description states it in the first sentence.\nThe 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.\n\nHere is the property the server adds to every Actor tool:\n\n```\n\"waitSecs\": {\n  \"type\": \"integer\",\n  \"minimum\": 0,\n  \"maximum\": 45,\n  \"default\": 30,\n  \"description\": \"Max seconds (0–45, default 30) to cap the wait for the Actor run to reach terminal state...\"\n}\n```\n\nForty-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:\n\n```\nRUNNING for 5s. In progress.\nUse get-actor-run with runId=jXPenvUnx5V3d5oHg and waitSecs=30 to poll for completion.\n```\n\nThen, polling:\n\n```\nRUNNING for 43s. In progress. 296 results so far.\nRUNNING for 74s. In progress. 485 results so far.\nSUCCEEDED in 77.813s. 485 items; 51 fields available.\n```\n\nSeventy-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*.\n\nThe 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.\n\nWhat it means for me as an Actor author:\n\n`296 results so far` line 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 shows `0 results so far` for 78 seconds, and an agent may well give up on it.\nThe last measurement is the one I would put in front of anyone pricing an agent workflow.\n\nI 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.\n\nThen the same ten rows with the fields an agent actually needs to answer *what Python jobs are open in Berlin*:\n\n```\npost({\"jsonrpc\": \"2.0\", \"id\": 9, \"method\": \"tools/call\",\n      \"params\": {\"name\": \"get-dataset-items\",\n                 \"arguments\": {\"datasetId\": \"X7bLTUTlRMIyunaI5\",\n                               \"limit\": 10,\n                               \"fields\": \"title,company,location,salaryMin,salaryMax,applyUrl\"}}}, sid)\n```\n\n**2,579 characters.** Forty-five times smaller, same answer.\n\nNote 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:\n\n```\nMCP error -32602: Invalid arguments for tool \"get-dataset-items\".\nValidation errors: /fields: must be string.\n```\n\nA 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.\n\nThe 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.\n\nFour edits, all in the input schema, none in the scraping code:\n\n`keywords` says it is required unless career-site boards or start URLs are filled in. I did not add it to `required`, 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.`prefill` values are real, correct examples everywhere, now that I know the server promotes them into the description the model reads.\nOne 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.\n\nNone 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.\n\nIf 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.", "url": "https://wpnews.pro/news/i-made-my-apify-actor-an-ai-agent-tool-then-read-every-byte-it-sent-back", "canonical_source": "https://dev.to/nikita_iakovlev_415524c19/i-made-my-apify-actor-an-ai-agent-tool-then-read-every-byte-it-sent-back-1nbj", "published_at": "2026-09-11 05:05:36+00:00", "updated_at": "2026-09-11 05:25:59.011818+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-products"], "entities": ["Apify", "Apify MCP server", "Jobs API", "Claude Desktop", "Cursor", "Indeed", "LinkedIn"], "alternates": {"html": "https://wpnews.pro/news/i-made-my-apify-actor-an-ai-agent-tool-then-read-every-byte-it-sent-back", "markdown": "https://wpnews.pro/news/i-made-my-apify-actor-an-ai-agent-tool-then-read-every-byte-it-sent-back.md", "text": "https://wpnews.pro/news/i-made-my-apify-actor-an-ai-agent-tool-then-read-every-byte-it-sent-back.txt", "jsonld": "https://wpnews.pro/news/i-made-my-apify-actor-an-ai-agent-tool-then-read-every-byte-it-sent-back.jsonld"}}