# I made my Apify Actor an AI agent tool, then read every byte it sent back

> 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: 2026-09-11 05:05:36+00:00

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.

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](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.

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.

``` python
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.

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.

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:

`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.
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.

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:

`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.
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.

Four edits, all in the input schema, none in the scraping code:

`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.
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.
