DEV Community

Ai-Q Labs for Apify

Posted on

I had never read my own Actors the way an agent reads them

I have 23 Actors on the Apify Store. I have read their Console input forms hundreds of times - every
field label, every hint, every checkbox, because I wrote them and then fixed them and then fixed them
again.

I had never once read the tool definition an AI agent receives when it calls them through the
Apify MCP server.

The Console input form for one of my Actors: two array fields, each pre-populated with the prefill values I wrote for human users - facebook/create-react-app, babel/babel-eslint, npm:request. This is the interface I reviewed dozens of times.

This is what I kept looking at. It is not what my callers see.

Last week I did. Of the 246 field descriptions I have written across those 23 Actors, 53 arrive at
the agent exactly as I typed them
. That is 21.5%. The rest are altered on the way out.

Most of those alterations are documented, sensible, and probably improvements. That is not the point.
The point is that I shipped 23 tools without ever looking at what my callers actually receive, and
when I finally looked, I found three things the docs do not mention and one mistake that was entirely
mine.

Here is how to look, and what I found.

Reading your own tool definitions

The Apify MCP server speaks Streamable HTTP, the transport defined by the
Model Context Protocol. You post JSON-RPC to it, you get
tools/list back, and that response is the ground truth for what an agent sees.

The first trap costs a minute: the endpoint is /, not /mcp. Post to /mcp and the server
tells you off in plain English:

There is nothing at route POST /mcp?actors=... This Model Context Protocol (MCP) server supports the Streamable HTTP transport.

The second trap costs a little more: notifications/initialized answers with an empty body, so a
naive JSON.parse on every response throws. I wrote that bug while writing this article.

// Read your own Actors the way an AI agent reads them.
// Usage: APIFY_TOKEN=... node read-my-tools.mjs aiqlabs/sitemap-checker aiqlabs/pdf-inspector

const token = process.env.APIFY_TOKEN;
const actors = process.argv.slice(2).join(',');
const url = `https://mcp.apify.com/?token=${token}&actors=${encodeURIComponent(actors)}`;

async function rpc(body, sessionId) {
    const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' };
    if (sessionId) headers['mcp-session-id'] = sessionId;
    const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
    const sid = res.headers.get('mcp-session-id') ?? sessionId;
    const text = await res.text();
    if (!text.trim()) return { sid, payload: null }; // notifications answer 202 with no body
    const line = text.split('\n').find((l) => l.startsWith('data:'));
    return { sid, payload: JSON.parse(line ? line.slice(5) : text) };
}

const init = await rpc({
    jsonrpc: '2.0', id: 1, method: 'initialize',
    params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'read-my-tools', version: '1.0' } },
});
await rpc({ jsonrpc: '2.0', method: 'notifications/initialized' }, init.sid);
const { payload } = await rpc({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, init.sid);
Enter fullscreen mode Exit fullscreen mode

Point it at three of mine - say sitemap-checker and two
others - and it prints:

aiqlabs--github-repository-audit  (490 chars, 16 params)
  injected by the server : waitSecs
  prefill left in schema : repos, packages
  truncated at 500 chars : repos

aiqlabs--sitemap-checker  (421 chars, 12 params)
  injected by the server : waitSecs
  prefill left in schema : domains
Enter fullscreen mode Exit fullscreen mode

Three lines of output, three things worth knowing. Take them in order.

The budget nobody tells you about

The description an agent reads is not your description. It is this:

This tool calls the Actor "<user>/<slug>" and retrieves its output results.
Use this tool instead of the "call-actor" if user requests this specific Actor.
Actor description: <your description>
Enter fullscreen mode Exit fullscreen mode

The README states that descriptions are truncated at 500 characters (MAX_DESCRIPTION_LENGTH). That
limit applies to the whole string above, preamble included - so what you actually get is 500 minus
the preamble.

And the preamble is not a fixed cost. Your Actor's full name sits inside it, which means a longer
slug buys you a shorter description
:

budget = 338 − length("<user>/<slug>")
Enter fullscreen mode Exit fullscreen mode

Across my 23 that works out to 303 to 317 characters. domain-availability-checker - my longest
slug at 35 characters - gets 303. pdf-inspector gets 317. Nothing anywhere told me that naming an
Actor spends part of its description.

None of mine are over. But the margins are thinner than I would have guessed: my tightest is
github-repository-audit at 10 characters of headroom, then hacker-news-link-rot at 11 and
chrome-extension-audit at 12. Median description across the 23 is 278. Three of them are one
average sentence away from being delivered with the last clause replaced by ....

I found the real numbers late, and only because I checked. My first draft of this section said the
preamble was "174 characters, fixed" and the budget was 326 - measured off a single Actor, which
happened to be one of the longest-named ones, and then stated as a constant.

What the server changes, in numbers

Across 23 Actors and 246 author-written fields:

Change Fields Documented
Example values: ... appended 188 yes
items.description generated where I wrote none 36 no
prefill kept in the exposed JSON Schema 33 no
waitSecs parameter added 23 no
**REQUIRED** prefix added 17 yes
Possible values: ... appended 8 yes
description truncated at 500 chars 1 yes

Zero of my 246 fields had an empty description, so none of this is blanks being filled in. It is text
I wrote being changed.

The documented half is in the server's README and it is
worth reading once:

  • Descriptions are truncated to 500 characters (as defined in MAX_DESCRIPTION_LENGTH).
  • Required fields are explicitly marked with a REQUIRED prefix in their descriptions for compatibility with frameworks that may not handle the JSON schema properly.

I checked the second claim rather than trusting it: 17 fields are declared required in my schemas,
and all 17 carry the prefix in the tool definition. It does what it says.

The truncation that undoes itself

Exactly one of my 246 fields is longer than 500 characters: the repos field on
github-repository-audit, at 624. Its top-level description arrives cut, with the last 124 characters
replaced by ....

Those 124 characters read:

...they could not be checked, not because they came back clean. Give the package name in Packages instead if you need those three.

That is not decoration. That sentence exists to stop a caller reading a null as a clean result - the
single misreading this Actor was built to prevent. Truncation ate exactly the warning.

Except the agent still gets it. The server had also generated an items.description for that array
field - something I never wrote - containing the full 624 characters, byte for byte. A documented
truncation was quietly cancelled by an undocumented generation.

I want to be precise about how much this shows. One field in 246 was long enough to test it. I am
reporting a mechanism, not a rate. But the mechanism is worth knowing in both directions: if your long
description sits on an array, the full text survives in items; if it sits on a string, nothing
catches it.

Where does items.description come from? Of my 39 array fields, 36 got one. Twenty-four are exact
copies of the parent description, eleven are the parent plus the **REQUIRED** prefix, and one is a
structural expansion of editor: requestListSources into its url / method / payload / headers
shape. The three that got nothing all have an enum on their items.

That last case caught me out while I was measuring. I first recorded it as "my 138-character
description was replaced by a 19-character string" and nearly published that. It was wrong: the parent
description arrives intact, and the 19 characters are the title of the expanded object type. Nothing
was lost. I mention it because it is the kind of error that reads perfectly well in a draft.

The parameter you did not write

Every one of my 23 tools carries a parameter I have never declared:

{
  "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. For long-running Actors the response returns at the cap with the current run status; follow `nextStep` to poll via get-actor-run. Set to 0 to fire-and-forget."
}
Enter fullscreen mode Exit fullscreen mode

It is on the server's own tools too - get-actor-run carries it - so this is a server-wide
convention rather than something done to Actors specifically.

I am not reporting this as a defect. The description tells the agent precisely how to recover, which
is more than most timeouts do. I am reporting it because an author who has never read their tool
definition does not know their Actor now has a 45-second contract with its callers.
If your Actor
takes four minutes on a realistic input, every agent call returns before it finishes and the caller's
experience depends on whether their client follows nextStep. That is a design constraint on your
Actor, and it arrived without you.

One key, two layers

prefill survives into the exposed JSON Schema on 33 of my fields. It is not a
JSON Schema keyword; nothing in
the spec says what a consumer should do with it. An agent reading the schema sees a value
sitting next to my field and can reasonably read it as a suggestion.

I have met this key before. In an earlier article I traced a run that returned four rows when I had
asked about one repository: the platform was treating my prefill as a default and injecting it
into every call that omitted the field. Same key, different layer, different behaviour - and neither
behaviour is one I asked for when I filled in a Console form hint.

Then I let an agent choose between all 23

Reading the definitions tells you what arrives. It does not tell you whether the catalog works. So I
ran a second trial: give an agent all 23 descriptions and one realistic task, and see which tool it
reaches for.

I wrote seven tasks, fixed the intended answer for each one in a file before launching anything,
and wrote down what I expected to happen. Two independent agents per task, fourteen trials, no Actor
executed.

I expected failures. My catalog has obvious traps in it - three PDF tools, three tools that all touch
broken links, two that both check domains.

Fourteen out of fourteen picked the tool I intended. Three of my seven predictions were wrong, and
every one of them was wrong in the same direction: the agents were more careful than I gave them
credit for. Task 7 is a fair example. I expected http-status-checker to pull votes away from
dead-link-checker; instead both agents explained, unprompted, that a status checker can only test
URLs you already have and therefore cannot recover the URLs of a blog that moved two years ago.

So the selection layer was not the story. This was:

Confidence fell to medium in exactly 4 of the 14 trials, and a tie was declared in exactly those
same 4.
The other ten were unanimous, high, and tie-free. Those four trials are the two pairs
where I had written overlapping descriptions.

Here is one of them, in my own words, from the live catalog:

  • seo-ai-visibility-auditor - "classic SEO health and for AI/LLM discoverability - AI crawler access in robots.txt, llms.txt, structured data, metadata"
  • seo-audit-tool - "on-page and technical SEO, and adds four checks general auditors skip: AI crawler access in robots.txt, llms.txt, redirects that drop the path, and noindex sent via X-Robots-Tag"

The phrase "AI crawler access in robots.txt, llms.txt" appears verbatim in both. I wrote both
descriptions, months apart, and had never once read them next to each other.

Asked "is my robots.txt blocking AI crawlers like GPTBot", both agents picked
seo-ai-visibility-auditor, and both told me why the pick was thin:

both entries explicitly list "AI crawler access in robots.txt" as a check, so either would answer
the question; I picked seo-ai-visibility-auditor because AI/LLM discoverability is its stated
primary purpose rather than one of four add-on checks. Neither description names GPTBot
specifically, so the match rests on the "AI crawler access in robots.txt" wording alone.

That is a correct answer arrived at by elimination, with the reasoning shown and the weakness
declared. The agent did the honest thing. The duplication it had to route around is mine.

What I wanted to conclude here was: the signal is the confidence, not the choice - every medium
landed on a real duplication, so watch where the agent hesitates. I had the sentence written. Then I
measured it and had to take it out. That is the next section.

Two caveats regardless. n is 2 per task, all Claude, all in one harness - this is a check on my
catalog, not evidence about agents in general. And the agents saw name and description only; a real
client also reads the input schema, which might break a tie my prose leaves open.

Fixing it, and measuring the fix the same way

The two SEO Actors were never duplicates in the code. One takes a starting URL and crawls
(startUrls, crawlSite, maxPages); the other takes a list and audits it page by page (urls,
maxUrls). They are genuinely different tools. My descriptions simply never said so - they both led
with the checks, and the checks overlap.

So I rewrote both to lead with the input shape, and to name each other:

  • seo-ai-visibility-auditor - "...Give it a domain to crawl - for a fixed list of pages, use SEO Audit Tool instead."
  • seo-audit-tool - "Give it a list of URLs... One row per URL - to crawl a whole site instead, use SEO & AI Visibility Auditor."

Then I pulled tools/list again to confirm the agent-facing text had actually changed - no tool in
the catalog still carries the shared phrase - and ran the same task past three fresh agents. All
three: high, no tie. One explained it without being asked:

aiqlabs/seo-audit-tool also covers "AI crawler rules" but is scoped to a supplied list of URLs, and
both descriptions explicitly cross-reference each other to resolve that split, so this is not a tie.

Clean result. I nearly stopped there.

The control that ruined it

I also re-ran task 4 - the domain pair - as a control. I had not touched either of those two
descriptions.

pair edited? before after
SEO ✅ yes medium ×2, tie ×2 high ×3, tie none
domain no medium ×2, tie ×2 high ×2, tie none

The pair I did not fix improved exactly as much as the pair I did. So the confidence change cannot be
attributed to my rewrite, and the sentence I wanted to publish - watch where the agent hesitates -
does not survive its own control.

The cause is a flaw in my design, not a mystery. My before and after prompts were not identical. The
before agents were told "answer in exactly this structure and nothing else"; they produced answers and
then sat on them, and I had to chase them with a second message asking them to actually send it. The
after agents had the delivery instruction from the start. One changed variable, sitting right next to
the one I was trying to measure.

What survives is narrower and still worth having. The duplication was real: the same clause, verbatim,
in two live descriptions I wrote months apart. Four of fourteen before-trials declared a tie and all
four landed on the two pairs where my descriptions genuinely overlapped
- no false alarms among the
other ten. The fix shipped, and the shared phrase is gone from what agents receive. What I cannot tell
you is whether fixing it changed anything measurable, because I broke my own instrument while using it.

If I had measured only the pair I edited - which was my plan until I added the control as an
afterthought - I would have published a causal claim resting on a changed prompt.

One more honest note on that edit. While writing the new description I listed "soft 404s answering 200" as
one of the four checks. The Actor does detect those - there is a soft_404 code in its source - but
my own README defines the four as AI crawler rules, /llms.txt, path-dropping redirects, and
X-Robots-Tag noindex. I had quietly swapped one out. I caught it by reading the README before
publishing rather than after, which is the only reason it is a footnote instead of a correction.

What I changed

Four things, none of them large:

  1. I compute the budget per Actor - 338 − length("<user>/<slug>") - instead of assuming one number. Three of mine sit within 12 characters of losing a sentence.
  2. No field description over 500 characters unless the field is an array. On an array, items catches the overflow. On a string, it does not.
  3. tools/list before publish. It takes ten seconds and it is the only view of your Actor that your non-human callers actually have.
  4. Read your descriptions as a set, not one at a time. This is the one that actually cost me something. I wrote two SEO Actors months apart, gave them the same clause, and never once put the two sentences side by side. A catalog is a document; mine was 23 documents that had never met.

The wider point is smaller than a bug and more annoying than one. I built 23 tools for AI agents to
call and spent all of my review time in a form built for humans. The one interface my actual callers
use, I had never opened.

Top comments (0)