DEV Community

CharlesTechy for Apify

Posted on

Making My Instagram Analyzer Actor Safe For An Ai Agent To Call On Its Own

Making My Instagram Analyzer Actor Safe For An Ai Agent To Call On Its Own
Apify's MCP server (mcp.apify.com) exposes any published Actor as a callable tool for AI agents - Claude, Cursor, whatever supports MCP. An agent can search-actors, inspect one with fetch-actor-details, and call-actor without you writing any glue code. That sounded almost too easy, until I actually looked at what an agent would see if it called my Instagram Influencer Deep Analyzer with no context beyond its input schema.

The problem: my schema was written for a human filling out a form

The original usernames field in .actor/input_schema.json looked like this:

{
  "usernames": {
    "title": "Instagram Usernames",
    "type": "array",
    "description": "List of Instagram profile usernames to analyze (without @ symbol)",
    "editor": "stringList",
    "prefill": ["cristiano", "leomessi"],
    "example": ["username1", "username2"]
  }
}
Enter fullscreen mode Exit fullscreen mode

That's fine for a person looking at a form - the placeholder and example make the shape obvious. But an agent doesn't see a form. It sees the raw JSON schema and has to decide, from that alone, whether "usernames" is required, whether it can be empty, and whether duplicates matter. Nothing in the original schema said any of that explicitly - required was declared at the top level of the schema, but there was no minItems, so a technically-valid call with "usernames": [] would sail past validation and only fail once the Actor was already running, three lines into main.ts:

if (!input.usernames || input.usernames.length === 0) {
    throw new Error('At least one username is required');
}
Enter fullscreen mode Exit fullscreen mode

That's a wasted Actor run and a confusing error for an agent to parse and retry against - it has to read a runtime log message instead of getting told upfront by the schema that the call was invalid.

What I changed, and why each change matters for an agent specifically

I added minItems: 1 and uniqueItems: true directly to the schema:

{
  "usernames": {
    "title": "Instagram Usernames",
    "type": "array",
    "description": "List of Instagram profile usernames to analyze (without @ symbol). At least one is required.",
    "editor": "stringList",
    "prefill": ["cristiano", "leomessi"],
    "example": ["username1", "username2"],
    "minItems": 1,
    "uniqueItems": true
  }
}

Enter fullscreen mode Exit fullscreen mode

That moves the "at least one username" rule from a runtime error message into the schema itself, where an agent's tool-calling layer can reject a bad call before it ever starts an Actor run.
I did the same for the new fakeFollowerAlertThreshold field: instead of just describing it as a number, I bounded it (minimum: 0, maximum: 100) so an agent can't pass 150 and get a silently-clamped value back - the Actor's scorer already clamps with Math.min(100, Math.max(0, ...)) in src/scoring/ai-scorer.ts, but the schema should say so up front rather than let an agent discover the clamping by trial and error.
The slackConnector field is the one I was most careful with, because it's the field most likely to confuse an agent. It only does anything if a human has already authorized a Slack connector in Apify Console - an agent calling the Actor autonomously has no way to create one on the fly. I made that dependency explicit in the description rather than implicit:

{
  "slackConnector": {
    "description": "Optional Slack MCP connector. When set, the Actor posts an alert to Slack as soon as it finds a profile whose fake follower probability crosses the alert threshold. [...]"
  }
}
Enter fullscreen mode Exit fullscreen mode

An agent reading fetch-actor-details sees "optional" and a clear trigger condition, not just a bare resourceType: "mcpConnector" it has to guess the purpose of.

The output schema matters as much as the input schema

mcp.apify.com infers field-level types for an Actor's results from schema.json, which is what lets an agent reason about the response without running the Actor first and inspecting a sample. My schema.json already had every field typed, but a few were looser than they needed to be - postType inside recentPostInsights was just "type": "string" even though the code only ever emits 'photo' | 'video' | 'carousel' (see src/types.ts). I tightened it to an enum:

{
  "postType": { "type": "string", "enum": ["photo", "video", "carousel"] }
}
Enter fullscreen mode Exit fullscreen mode

That's a small change, but it's the difference between an agent treating postType as an open string it has to pattern-match against, versus a closed set it can branch on directly - useful if, say, an agent is asked to "summarize only the video posts" and needs to filter reliably.

What an agent workflow around this actually looks like

Once the Actor is published, a client like Claude Desktop or Cursor only needs the hosted server URL:

{
  "mcpServers": {
    "apify": { "url": "https://mcp.apify.com" }
  }
}
Enter fullscreen mode Exit fullscreen mode

From there the agent's own reasoning drives the call: it searches the Store for something matching "Instagram engagement analysis," pulls up my Actor's input/output schema via fetch-actor-details, and calls it with call-actor using whatever usernames the user mentioned in conversation. No integration code on my side beyond the schema itself.
To test this end-to-end, I pointed Cursor at https://mcp.apify.com and gave it a high-level user prompt:

Check if @growth_brand_test looks suspicious for fake followers, and summarize their top 3 video posts.

Without any pre-coded logic on my part, Cursor called fetch-actor-details for charlestechy/instagram-influencer-deep-analyzer. Reading the updated input schema, it automatically structured a valid JSON payload:

{
  "usernames": ["growth_brand_test"],
  "getPosts": true,
  "maxPosts": 12,
  "deepMetadata": true
}
Enter fullscreen mode Exit fullscreen mode

Because minItems: 1 was enforced in the schema, the client validated "usernames" before dispatching the remote request. And because postType in schema.json was constrained to ["photo", "video", "carousel"], the agent immediately knew how to filter the recentPostInsights array down to postType === "video" without needing an extra round-trip to ask what values postType might hold.

Instagram Influencer Deep Analyzer

Figure 1: Cursor calling the Instagram Influencer Deep Analyzer Actor via mcp.apify.com and parsing structured JSON output.

What I'd tighten next

After testing several prompts, two areas stood out for future refinement:
1. Payload size management for LLM context windows: When getPosts is enabled for multiple usernames, the dataset output can grow large. While Claude 3.5 Sonnet handles the context easily, smaller local models can get overwhelmed. I plan to add a compactOutput: true flag in the input schema to return only high-level scoring metrics when full post breakdown isn't strictly required.
2. Explicit default behavior in schema descriptions: While maxPosts has a schema default of 12, explicitly stating "(defaults to 12 if unspecified)" in the field description helps agents decide whether to override it based on the user's prompt (e.g. if the user asks for a "quick sanity check" vs a "deep audit").
Try it yourself
The full before/after schema is in .actor/input_schema.json and schema.json in the Actor's source. If you're making your own Actor agent-callable, the questions I'd ask of every field are the same ones above: can an invalid call be rejected by the schema instead of at runtime, is every optional field's trigger condition spelled out in its description, and does the output schema constrain enums wherever the code already does?

Suggested meta description: What I changed in an Apify Actor's input and output schema to make it reliably callable by an AI agent through the Apify MCP server, and the validation gaps a form-first schema leaves open.

Top comments (0)