DEV Community

See Siang Ang
See Siang Ang

Posted on

Article for Apify

From pay-per-result Actor to AI agent tool: a real Apify MCP call

Complete code: github.com/seesianga/robotgames-apify-integration

I had already built and published a pay-per-result Actor when I loaded it into the Apify MCP server. Across captured Streamable HTTP exchanges, hand-written MCP clients discovered the Actor as a dedicated tool, called it, and followed the server's instruction to fetch its Dataset. The Actor needed zero agent-specific code: its existing input schema and Store description became the tool contract.

That Actor grew out of Robotgames, a live multiplayer game. On its nominal 06:15 UTC timer, the game starts six public Store Actors across Instagram, TikTok, and YouTube. I wanted daily social sentiment across those platforms without maintaining six crawling stages myself: discovery and comments for each platform. The public Actors handle collection; the game normalizes, classifies, and stores the results.

Datasets are the exchange boundary in both directions. Actor runs hand collected items to the game, while a separate mirror publishes selected database tables as named Datasets without opening the production database. I then packaged a related sentiment workflow as the pay-per-result ALL-IN-ONE Market Sentiment Marker. Section 6 shows the captured MCP exchange that turned that already-published Actor into an AI-agent tool.

The game has other web-data jobs that run outside Apify and are out of scope in this article.

1. The stack at a glance

The Apify paths look like this:

SOCIAL · DAILY · APIFY
06:15 UTC -> discover + comments on Instagram / TikTok / YouTube
             six public Store Actors -> default Datasets -> normalize
             -> classify -> SQLite social_posts / social_comments / social_runs

EXPORTS · ON DEMAND · APIFY
selected SQLite tables -> redact credentials -> named Apify Datasets

PUBLISHED TOOL · APIFY MCP
AI agent / MCP client -> Apify MCP server -> ALL-IN-ONE Market Sentiment Marker
                                            -> Dataset id -> paged result rows
Enter fullscreen mode Exit fullscreen mode

The social timer has up to 900 seconds of randomized delay, so 06:15 is the nominal trigger rather than a promise that all six runs start at that second. The Store Actors are also not one interchangeable scraper. There is a discovery stage and a comments stage for each of three platforms, and each publisher has its own input and output contract.

[SCREENSHOT 1 — Apify Console run history showing the six daily social Actor runs around the nominal 06:15 UTC trigger]

2. The daily sweep: start, poll, page the Dataset, then normalize

The daily job runs these public Actors:

The game does not install apify-client for this job. Its Node 20 client is deliberately small: start a run through the REST API, poll until the run leaves READY or RUNNING, and page the default Dataset 1,000 items at a time. Tokens go in an Authorization header, never in a query string.

// Source: server-node/scripts/social/apify.js (CommonJS, condensed)
'use strict';

const DEFAULTS = {
  pollMs: 5000,
  maxWaitMs: 15 * 60_000,
  runTimeoutSecs: 900,
};

async function runActor(actorId, input, opts = {}) {
  const cfg = { ...DEFAULTS, ...opts };
  const label = cfg.label || actorId;

  const started = await api(
    'POST',
    `/v2/acts/${actorId}/runs?timeout=${cfg.runTimeoutSecs}`,
    { body: input },
  );
  const run = started?.data || {};
  const runId = run.id;
  const datasetId = run.defaultDatasetId;
  if (!runId) throw new Error(`[${label}] Apify did not return a run id`);

  const deadline = Date.now() + cfg.maxWaitMs;
  let status = run.status || 'READY';
  while (status === 'READY' || status === 'RUNNING') {
    if (Date.now() > deadline) {
      try { await api('POST', `/v2/actor-runs/${runId}/abort`); } catch (_) {}
      throw new Error(`[${label}] run exceeded ${Math.round(cfg.maxWaitMs / 1000)}s — aborted`);
    }
    await sleep(cfg.pollMs);
    status = (await api('GET', `/v2/actor-runs/${runId}`))?.data?.status || status;
  }

  const items = [];
  const pageSize = 1000;
  for (let offset = 0; ; offset += pageSize) {
    const qs = `?clean=true&format=json&limit=${pageSize}&offset=${offset}`;
    const page = await api('GET', `/v2/datasets/${datasetId}/items${qs}`);
    const rows = Array.isArray(page) ? page : [];
    items.push(...rows);
    if (rows.length < pageSize || (cfg.maxItems && items.length >= cfg.maxItems)) break;
  }

  return {
    items: cfg.maxItems ? items.slice(0, cfg.maxItems) : items,
    runId,
    status,
    datasetId,
  };
}
Enter fullscreen mode Exit fullscreen mode

Why not use an actor.call()-style wait? The published Actor does use Actor.call() internally because it already runs inside Apify. The game-side script needs different controls: no extra dependency, its own 15-minute deadline with a best-effort abort, partial-run status in the return value, explicit pagination, and an optional item cap. Starting and polling makes those choices visible.

scrape.js then performs discovery and comments as separate stages. The snippet below keeps the real Actor IDs and input-field names; normalization and logging are shortened.

// Source: server-node/scripts/social/scrape.js (CommonJS, condensed)
'use strict';

async function scrapeInstagram() {
  const discovered = await runActor('apify~instagram-hashtag-scraper', {
    hashtags: HASHTAGS,
    resultsType: 'posts',
    resultsLimit: vol.postsPerPlatform,
  }, { maxItems: vol.postsPerPlatform, label: 'ig/discover' });
  const posts = discovered.items.map((item) => normPost('instagram', item)).filter((post) => post.url);
  const urls = uniq(posts.map((post) => post.url)).slice(0, vol.postsPerPlatform);
  if (!urls.length) return { posts, comments: [] };

  const collected = await runActor('apify~instagram-comment-scraper', {
    directUrls: urls,
    resultsLimit: vol.commentsPerPost,
    includeNestedComments: true,
  }, { label: 'ig/comments' });
  return { posts, comments: collected.items.map((item) => normComment('instagram', item)).filter(Boolean) };
}

async function scrapeTikTok() {
  const discovered = await runActor('clockworks~tiktok-scraper', {
    searchQueries: TERMS,
    hashtags: HASHTAGS,
    resultsPerPage: vol.postsPerPlatform,
  }, { maxItems: vol.postsPerPlatform * 2, label: 'tiktok/discover' });
  const posts = discovered.items.map((item) => normPost('tiktok', item)).filter((post) => post.url);
  const urls = uniq(posts.map((post) => post.url)).slice(0, vol.postsPerPlatform);
  if (!urls.length) return { posts, comments: [] };

  const collected = await runActor('clockworks~tiktok-comments-scraper', {
    postURLs: urls,
    commentsPerPost: vol.commentsPerPost,
    maxRepliesPerComment: 0,
  }, { label: 'tiktok/comments' });
  return { posts, comments: collected.items.map((item) => normComment('tiktok', item)).filter(Boolean) };
}

async function scrapeYouTube() {
  const discovered = await runActor('streamers~youtube-scraper', {
    searchQueries: TERMS,
    maxResults: vol.postsPerPlatform,
    maxResultsShorts: 0,
    maxResultStreams: 0,
  }, { maxItems: vol.postsPerPlatform, label: 'yt/discover' });
  const posts = discovered.items.map((item) => normPost('youtube', item)).filter((post) => post.url);
  const urls = uniq(posts.map((post) => post.url)).slice(0, vol.postsPerPlatform);
  if (!urls.length) return { posts, comments: [] };

  const collected = await runActor('streamers~youtube-comments-scraper', {
    startUrls: urls.map((url) => ({ url })),
    maxComments: vol.commentsPerPost,
    sortCommentsBy: 'TOP_COMMENTS',
  }, { label: 'yt/comments' });
  return { posts, comments: collected.items.map((item) => normComment('youtube', item)).filter(Boolean) };
}

const SCRAPERS = { ig: scrapeInstagram, tiktok: scrapeTikTok, youtube: scrapeYouTube };

async function runConfiguredScrapers(platforms, db) {
  const byPlatform = {};
  const allComments = [];
  for (const platform of platforms) {
    const result = await SCRAPERS[platform]();
    byPlatform[platform] = result;
    allComments.push(...result.comments);
  }
  await classifyComments(allComments);
  const allPosts = Object.values(byPlatform).flatMap((result) => result.posts);
  store(db, allPosts, allComments, Date.now());
}
Enter fullscreen mode Exit fullscreen mode

classifyComments() first applies the game-specific lexicon and can optionally send only neutral items through an LLM refinement pass. store() upserts the normalized rows into social_posts and social_comments; the run summary goes into social_runs. That operation is idempotent at the row level, so another day's sweep updates engagement and sentiment without duplicating the same post or comment.

This is the systemd.timer unit exactly as checked in:

[Unit]
Description=Run the robotgames social sentiment scrape once a day

[Timer]
# Once a day at 06:15 UTC. RandomizedDelaySec spreads load / avoids
# hammering the platforms at exactly the same second each day.
OnCalendar=*-*-* 06:15:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target
Enter fullscreen mode Exit fullscreen mode

The ClickHouse mirror is not the next line of this pipeline. robotgames-ch-sync.timer launches a separate full-reload job hourly, and the loader's fixed table list currently contains no social_* table. The daily sweep's checked-in destination is SQLite.

[SCREENSHOT 2 — A successful social sub-Actor run and its default Dataset items, showing the run-to-Dataset boundary]

3. Ownership, billing, and resource identity are separate

My first Apify account, angseesiang, owns the original monetized Store listing. It later ran out of platform credit, so a second account, s4ndid, became the active account for Actor runs and Dataset writes. Moving execution was easy; pretending account-scoped IDs were portable would not have been.

The replication also left a second public copy at apify.com/s4ndid/all-in-one-market-sentiment-marker. When I verified it on 14 August 2026, that copy had 34 runs and no monetization configured. The copy's current visibility is a listing decision; it does not change the architecture lesson. Code ownership, the account billed for a run, and the IDs of resources created by that run are three different concerns.

The resolver is CommonJS and intentionally boring. It tries account #2 before account #1; for each key, process.env wins over the same key in ~/.claude.json. It returns metadata alongside the token without logging the full credential.

// Source: server-node/scripts/apify-token.js (CommonJS, condensed)
'use strict';

const fs = require('fs');
const path = require('path');
const os = require('os');

let claudeCfg;
function fromClaudeJson(key) {
  if (claudeCfg === undefined) {
    try {
      claudeCfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'));
    } catch (_) {
      claudeCfg = null;
    }
  }
  return (claudeCfg && claudeCfg[key]) || '';
}

function resolveApifyToken() {
  for (const [envVar, account] of [
    ['APIFY2_API_TOKEN', 2],
    ['APIFY_API_TOKEN', 1],
  ]) {
    const token = process.env[envVar] || fromClaudeJson(envVar);
    if (token) return { token, account, envVar };
  }
  return { token: '', account: null, envVar: null };
}

const BASE_URL = (process.env.APIFY_BASE_URL || 'https://api.apify.com').replace(/\/+$/, '');

async function datasetIdByName(name, token) {
  const activeToken = token || resolveApifyToken().token;
  const response = await fetch(`${BASE_URL}/v2/datasets?limit=1000&unnamed=false`, {
    headers: { Authorization: `Bearer ${activeToken}`, Accept: 'application/json' },
  });
  if (!response.ok) throw new Error(`apify datasets ${response.status}`);
  const items = ((await response.json()).data || {}).items || [];
  return items.find((dataset) => dataset.name === name)?.id || null;
}

async function actorIdByName(name, token) {
  const activeToken = token || resolveApifyToken().token;
  const response = await fetch(`${BASE_URL}/v2/acts?my=1&limit=1000`, {
    headers: { Authorization: `Bearer ${activeToken}`, Accept: 'application/json' },
  });
  if (!response.ok) throw new Error(`apify acts ${response.status}`);
  const items = ((await response.json()).data || {}).items || [];
  return items.find((actor) => actor.name === name)?.id || null;
}

module.exports = { resolveApifyToken, datasetIdByName, actorIdByName };
Enter fullscreen mode Exit fullscreen mode

Names are my cross-account handles; IDs are not. apify-actor/replicate-account.js copied Actor settings, source versions, environment metadata, and build tags from account #1 to account #2, then built the copies. Datasets are regenerated by the mirror in the next section. The replicator also records two limits that matter operationally: Store visibility and monetization require separate Console configuration rather than following the source automatically.

The resolver does not merge listings or make resource IDs portable. It gives scheduled and manual scripts a consistent answer to “which account should pay for this operation?” while the original monetized listing remains with account #1 and account #2 owns the resources it creates.

4. The inverse integration: SQLite into named Apify Datasets

The social sweep consumes Actor Datasets. server-node/scripts/push-to-apify.js travels the other way: it publishes selected SQLite tables into one named Dataset per table. For example, social_comments becomes robotgames-social-comments.

The mirror is a full reload. It deletes and recreates the named Dataset before pushing the current snapshot, which trades stable Dataset IDs for an easy guarantee: rerunning the job does not append duplicates. A separate manifest Dataset records the table name, row count, target Dataset, redacted columns, and sync time.

Security has two layers. Credential-looking columns are replaced with ***REDACTED*** by default. --analytics-only additionally removes an explicit set of tables containing player PII before any Dataset is created. It is an exclusion list, not a claim that every remaining column is harmless, so the dry run and manifest still deserve review.

// Source: server-node/scripts/push-to-apify.js (CommonJS, condensed)
'use strict';

const REDACT = !args.noRedact;
const SECRET_WORD_RE = /(^|_)(salt|password|passwd|secret|mnemonic|apikey|api_key|privkey|private_key|seed_phrase|seedphrase)($|_)/i;
const SECRET_EXACT = new Set([
  'hash', 'token', 'token_hash', 'password_hash', 'pw_hash',
  'access_token', 'refresh_token', 'auth_token', 'session_token', 'api_token',
]);
const PERSONAL_TABLES = new Set([
  'users', 'sessions', 'saves', 'friends', 'friend_requests', 'blocks',
  'chat_log', 'voice_sessions', 'voice_transcripts',
  't3_identities', 't3_credentials', 't3_consents', 't3_anticheat',
  't3_ledger', 't3_rewards',
]);

function datasetName(table) {
  let name = (PREFIX + table).toLowerCase()
    .replace(/[^a-z0-9-]+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-+/, '')
    .replace(/-+$/, '');
  return name.slice(0, 63).replace(/-+$/, '');
}

function normaliseRow(row, secretCols) {
  const output = {};
  for (const key of Object.keys(row)) {
    if (secretCols.has(key)) {
      output[key] = '***REDACTED***';
      continue;
    }
    let value = row[key];
    if (typeof value === 'bigint') value = Number(value);
    else if (Buffer.isBuffer(value)) value = value.toString('base64');
    output[key] = value;
  }
  return output;
}

async function resetDataset(name) {
  const existing = await getOrCreateDataset(name);
  if (existing.id) await deleteDataset(existing.id);
  const fresh = await getOrCreateDataset(name);
  if (!fresh.id) throw new Error(`could not recreate dataset "${name}"`);
  return fresh;
}

async function mirrorTables(db, tables) {
  if (args.analyticsOnly) {
    tables = tables.filter((table) => !PERSONAL_TABLES.has(table));
  }

  for (const table of tables) {
    const columns = db.prepare(`PRAGMA table_info("${table}")`).all().map((column) => column.name);
    const secretCols = new Set(REDACT ? columns.filter(
      (name) => SECRET_WORD_RE.test(name) || SECRET_EXACT.has(String(name).toLowerCase()),
    ) : []);
    const rows = db.prepare(`SELECT * FROM "${table}"`).all()
      .map((row) => normaliseRow(row, secretCols));
    if (!rows.length && !args.includeEmpty) continue;

    const dataset = await resetDataset(datasetName(table));
    if (rows.length) await pushItems(dataset.id, rows);
  }
}
Enter fullscreen mode Exit fullscreen mode

--no-redact exists for an explicit, dangerous opt-out; it is not the normal path. The active-account resolver supplies the token, so the same Dataset names can be recreated on account #2 even though their IDs differ from account #1.

[SCREENSHOT 3 — A named robotgames Dataset and robotgames-manifest in Apify Console, including row count and redaction metadata]

5. Productizing the workflow as a pay-per-result Actor

The public result of this work is ALL-IN-ONE Market Sentiment Marker. Its code lives in apify-actor/all-in-one-market-sentiment-marker/.

It is related to the game sweep, but it is not a copy. The Actor adapts the game's core lexicon mechanics — tokenization, a two-token negation window, phrase and emoji scoring, and optional LLM refinement of neutral items — for general brand language. The dictionaries and prompts differ. The game version knows terms such as “goat,” “poggers,” and “dead game”; the Actor version adds market language such as “bullish,” “layoffs,” and “record high.” Calling them the same classifier would hide a real productization decision.

Actorizing the workflow meant four concrete changes: generalize the classifier's vocabulary and prompt, expose collection choices as a typed input schema, make row ordering and cost controls part of the output contract, and finish publication through the human-only Console gates. The sections below follow those changes rather than presenting the Actor as a thin wrapper around the game script.

Make the actual contract the product surface

Only brand is required. The field that chooses channels is named platforms, not channels, and defaults to news. Social platforms are optional because each invokes paid public Actors on the caller's account. Competitor input is competitors[], not benchmark, and competitor scoring currently uses Google News results only.

This is the relevant part of the checked-in input schema:

{
  "type": "object",
  "schemaVersion": 1,
  "properties": {
    "brand": { "type": "string", "editor": "textfield" },
    "platforms": {
      "type": "array",
      "editor": "select",
      "items": {
        "type": "string",
        "enum": ["news", "instagram", "tiktok", "youtube"]
      },
      "default": ["news"]
    },
    "competitors": { "type": "array", "editor": "stringList" },
    "maxItems": { "type": "integer", "default": 0, "minimum": 0 },
    "includeRawItems": { "type": "boolean", "default": true }
  },
  "required": ["brand"]
}
Enter fullscreen mode Exit fullscreen mode

The full schema also exposes keywords, hashtags, per-platform post/comment limits, a news limit, lexicon-versus-LLM selection, a secret API-key field for LLM refinement, the model name, and optional proxy configuration. Those are not decorative controls: the social volume affects upstream Actor charges, while includeRawItems and maxItems affect this Actor's billable output.

[SCREENSHOT 4 — The ALL-IN-ONE Market Sentiment Marker input form beside a completed run's marker-first Dataset output]

Put the marker first and treat every row as a charge

The index keeps neutral voices in its denominator:

round((positive − negative) / (positive + negative + neutral) × 100)

That yields an integer from −100 to +100, or zero when there are no voices. The Actor writes one _kind: "market_sentiment" marker first, then platform and competitor breakdowns, then optional raw post, comment, and news rows. Because pushRow() observes maxItems, the marker remains first even when the caller caps the Dataset.

// Source: apify-actor/all-in-one-market-sentiment-marker/main.js (ESM, condensed)
import { Actor } from 'apify';

await Actor.init();
const input = (await Actor.getInput()) ?? {};
const brand = String(input.brand || '').trim();
if (!brand) await Actor.fail('Input "brand" is required.');

const maxItems = Number.isInteger(input.maxItems) && input.maxItems > 0
  ? input.maxItems
  : 0;
const includeRawItems = input.includeRawItems !== false;

function tally(items) {
  const totals = { positive: 0, negative: 0, neutral: 0 };
  for (const item of items) totals[item.sentiment] = (totals[item.sentiment] || 0) + 1;
  const total = totals.positive + totals.negative + totals.neutral;
  const index = total
    ? Math.round(((totals.positive - totals.negative) / total) * 100)
    : 0;
  return { ...totals, total, index, label: indexLabel(index, total) };
}

// At this point, `comments` and `news` contain the primary brand's voices.
const overall = tally([...comments, ...news]);
// `perPlatform` is tallied from those same brand voices. Separate News-only
// competitor queries populate `competitorMarks` afterward; optional raw
// competitor headlines may be appended to `news`, but never alter `overall`.
const output = [];
const pushRow = (row) => {
  if (!maxItems || output.length < maxItems) output.push(row);
};

const marker = {
  _kind: 'market_sentiment',
  brand,
  index: overall.index,
  label: overall.label,
  positive: overall.positive,
  negative: overall.negative,
  neutral: overall.neutral,
  totalVoices: overall.total,
  platforms: perPlatform,
  competitors: competitorMarks.map((competitor) => ({
    competitor: competitor.competitor,
    index: competitor.index,
    label: competitor.label,
    total: competitor.total,
  })),
  scannedAt: new Date().toISOString(),
};
pushRow(marker);

for (const [platform, totals] of Object.entries(perPlatform)) {
  pushRow({ _kind: 'platform_sentiment', brand, platform, ...totals });
}
for (const competitor of competitorMarks) {
  pushRow({ _kind: 'competitor_sentiment', brand, ...competitor });
}
if (includeRawItems) {
  for (const post of posts) pushRow({ _kind: 'post', brand, ...post });
  for (const comment of comments) pushRow({ _kind: 'comment', brand, ...comment });
  for (const item of news) pushRow({ _kind: 'news', brand, ...item });
}

await Actor.pushData(output);
try {
  if (output.length) {
    await Actor.charge({ eventName: 'result', count: output.length });
  }
} catch (error) {
  console.warn(`Charging skipped: ${error.message}`);
}
await Actor.setValue('SUMMARY', { ...marker, errors, engine });
await Actor.exit();
Enter fullscreen mode Exit fullscreen mode

The repository's changelog documents the result event at $0.001 per row: $1 per 1,000 Dataset rows. After Actor.pushData(output), the implementation requests one result event per output row; local or free runs can skip charging without failing the run. This makes output shape a cost decision. A scores-only run can set includeRawItems: false; maxItems imposes a harder ceiling. Charges from the social sub-Actors remain separate and belong to their respective authors. Apify's Actor monetization documentation describes the publication-side pricing models.

Publication added a non-code step. The Actor can be built through the normal Apify tooling, but Store visibility, terms acceptance, and pay-per-result configuration live in Console. Those settings also do not follow the source when an Actor is replicated to another account.

[SCREENSHOT 5 — The public Store listing for ALL-IN-ONE Market Sentiment Marker, including title, publisher and pricing]
[SCREENSHOT 6 — The Actor Publication/Monetization view showing the result event priced at $0.001 per Dataset row]

The same contract became the agent-tool contract

The required input, typed options, deterministic row kinds, and JSON Dataset output did more than make a tidy API. When I loaded the Actor into the Apify MCP server, that existing contract became a dedicated tool without an agent-specific adapter. Section 6 shows the captured tools/list schema, the real tools/call, and the Dataset follow-up that proves it.

An agent can request “brand X versus competitors Y and Z” and receive a marker plus comparison rows. Its instructions should also say that the competitor comparison is News-only today, even if the primary brand scan includes social platforms.

6. Calling the published Actor through the Apify MCP server

I wanted wire evidence before calling the Actor an agent tool. On 13 August 2026, I connected a small Node client to https://mcp.apify.com/?actors=angseesiang/all-in-one-market-sentiment-marker. The endpoint loaded just this Actor. The client authenticated with Authorization: Bearer <APIFY_API_TOKEN>, negotiated the MCP 2025-06-18 specification over Streamable HTTP, accepted text/event-stream responses, and returned the server's Mcp-Session-Id header on later requests in the same session. The initialization response identified apify-mcp-server version 0.14.3.

Five tools, one generated contract

tools/list returned exactly five tools:

  1. angseesiang--all-in-one-market-sentiment-marker
  2. get-actor-run
  3. get-dataset-items
  4. get-key-value-store-record
  5. abort-actor-run

The Actor slug's / became -- in the dedicated tool name. Its description incorporated the Store listing, and the input schema was derived from .actor/input_schema.json: brand remained required, the Actor's 13 optional inputs remained available, and the MCP server added optional waitSecs as a fourteenth control. I wrote zero agent-specific code for that tool surface; the published description and input schema did the work.

The captured call

This is the core of the hand-written client used for the capture, condensed from the environment-safe public-repository copy:

// Source: mcp-agent-demo/mcp-call-actor.mjs (ESM, condensed)
const TOKEN = process.env.APIFY_API_TOKEN;
if (!TOKEN) throw new Error('APIFY_API_TOKEN is required');

const ACTOR = 'angseesiang/all-in-one-market-sentiment-marker';
const TOOL = 'angseesiang--all-in-one-market-sentiment-marker';
const ENDPOINT = `https://mcp.apify.com/?actors=${encodeURIComponent(ACTOR)}`;
let sessionId = null;
let nextId = 1;

async function rpc(method, params, { notify = false } = {}) {
  const body = notify
    ? { jsonrpc: '2.0', method, params }
    : { jsonrpc: '2.0', id: nextId++, method, params };
  const headers = {
    Authorization: `Bearer ${TOKEN}`,
    'Content-Type': 'application/json',
    Accept: 'application/json, text/event-stream',
  };
  if (sessionId) {
    headers['Mcp-Session-Id'] = sessionId;
    headers['MCP-Protocol-Version'] = '2025-06-18';
  }

  const response = await fetch(ENDPOINT, {
    method: 'POST',
    headers,
    body: JSON.stringify(body),
  });
  sessionId ||= response.headers.get('mcp-session-id');
  if (notify || response.status === 202) return null;

  const contentType = response.headers.get('content-type') || '';
  const text = await response.text();
  let message;
  if (contentType.includes('text/event-stream')) {
    for (const line of text.split('\n')) {
      if (!line.startsWith('data:')) continue;
      const event = JSON.parse(line.slice(5).trim());
      if (event.id !== undefined || event.result || event.error) message = event;
    }
  } else {
    message = JSON.parse(text);
  }
  if (message?.error) throw new Error(`${method}: ${JSON.stringify(message.error)}`);
  return message?.result;
}

await rpc('initialize', {
  protocolVersion: '2025-06-18',
  capabilities: {},
  clientInfo: { name: 'robotgames-agent', version: '1.0.0' },
});
await rpc('notifications/initialized', {}, { notify: true });

const result = await rpc('tools/call', {
  name: TOOL,
  arguments: {
    brand: 'Nike',
    competitors: ['Adidas'],
    platforms: ['news'],
    maxNewsPerQuery: 25,
    includeRawItems: false,
  },
});
console.log(result.structuredContent);
Enter fullscreen mode Exit fullscreen mode

The call created run 64AaUjbkhXDzTjulx, which reached SUCCEEDED with exit code 0. The Actor itself ran for 4.957 seconds; the MCP tools/call round trip took 18.7 seconds. It produced three Dataset rows. At the configured $0.001-per-result price, that output is $0.003, and the run metadata reported 0.00138 compute units.

The first Dataset row, fetched by the follow-up call, was:

{
  "_kind": "market_sentiment",
  "brand": "Nike",
  "index": 16,
  "label": "positive",
  "positive": 4,
  "negative": 0,
  "neutral": 21,
  "totalVoices": 25,
  "platforms": {
    "news": { "positive": 4, "negative": 0, "neutral": 21, "total": 25, "index": 16, "label": "positive" }
  },
  "competitors": [
    { "competitor": "Adidas", "index": 0, "label": "neutral", "total": 25 }
  ],
  "scannedAt": "2026-08-13T16:30:02.852Z"
}
Enter fullscreen mode Exit fullscreen mode

The important part was the second tool call

The Actor tool response did not place those rows in the first response. It returned run metadata, Dataset id 93VOkjvdUgc2vGoVi, a 22-field list, and this literal next step:

“Use get-dataset-items with datasetId=93VOkjvdUgc2vGoVi and limit (for example 20) to fetch items (3 total).”

The follow-up script opened a fresh MCP session and called get-dataset-items with that Dataset id and limit: 20. It returned the three rows and reported: “Fetched all 3 items. No more pages. Inspect the returned items directly.” This two-step pattern keeps a potentially large scrape out of the initial tool response and lets an agent choose a limit or field projection before spending context on rows. It does not make the context window unlimited; it gives the caller a place to bound and page the data.

The live answer also moved. The same Nike-versus-Adidas tool call recorded on 11 July 2026 returned +10 for Nike and -3 for Adidas. On 13 August it returned +16 and 0. Same tool and arguments, different week, different live result — exactly why an agent may need a live-data tool rather than relying on model memory.

The defaults in your schema become the agent's spending policy

Here is the thing I did not see coming, and it is the one I would fix first in any Actor meant for agents.

My captured call passed platforms: ["news"], maxNewsPerQuery: 25 and includeRawItems: false, and produced 3 rows for $0.003. But brand is the only required field. An agent that fills in the required field and nothing else — which is exactly what a well-behaved agent does — sends {"brand": "Nike"} and inherits my schema's defaults: includeRawItems is true, maxNewsPerQuery is 50, and maxItems is 0, meaning no cap. That run returns roughly 52 billable rows instead of 3. Same tool, same question, about seventeen times the cost.

I checked the tool schema the MCP server advertised, and those defaults are carried through verbatim from .actor/input_schema.json. One thing does not carry: prefill. My schema prefills brand with "Nike", which is what a human sees in the Console form, but prefill is a Console affordance and never reaches an MCP caller. Only default does.

For a human clicking through the Console form, generous defaults are hospitality. For an agent on pay-per-result, defaults are a spending policy that the caller never consciously agreed to. The agent has no idea what a row costs. If I were designing this Actor for agents from the start, I would default includeRawItems to false, give maxItems a real ceiling, and put the price per row in the field descriptions where the model will actually read it.

For Claude Desktop, Cursor, or another compatible MCP client, the equivalent setup is:

{"mcpServers":{"apify":{"url":"https://mcp.apify.com/?actors=angseesiang/all-in-one-market-sentiment-marker","headers":{"Authorization":"Bearer <APIFY_API_TOKEN>"}}}}
Enter fullscreen mode Exit fullscreen mode

That block is setup guidance. I did not use Claude Desktop or Cursor for the captured run; I used the hand-written client above so I could preserve the JSON-RPC exchange.

[SCREENSHOT 7 — The MCP-started Actor run 64AaUjbkhXDzTjulx in Apify Console; optionally a Claude Desktop or Cursor tool-call view]

7. Incidents and lessons learned

The useful lessons came from failures and awkward edges, not the happy-path diagram.

Incident 1: the account with the listing ran out of credit

The history is preserved in server-node/scripts/apify-token.js and apify-actor/replicate-account.js, introduced in commit 77667ac. Account #1 retained the original monetized listing, while account #2 had to take over paid work and ended up with a separate public, unmonetized copy. The fix was not to overwrite the old token everywhere. I centralized precedence, cloned the fleet, and changed helpers to resolve resources by name on the active account. The durable lesson is that listing ownership, execution billing, and resource identity are three separate concerns.

Incident 2: an intuitive enum value was still wrong

The YouTube comments Actor rejected sortCommentsBy: 'top'; its accepted enum is TOP_COMMENTS. The working value and warning are preserved in apify-actor/all-in-one-market-sentiment-marker/sources.js, commit 6496fa0. Store Actors are API dependencies. Read their input schema and copy enum values exactly instead of guessing from a plausible label.

Incident 3: publication stopped at human-only gates

apify-actor/publish.js records two API errors: username-required and store-terms-not-accepted. Enabling the owner's public profile and accepting the Store terms are Console-only actions; attempts to update the user through the API returned HTTP 405. This evidence also comes from commit 6496fa0. Builds can be automated, but the public-profile and Store-terms gates belong on a human release checklist.

What I would repeat

  • Keep the integration loop explicit. Start, poll, page, normalize, classify, store is easy to observe and easy to bound.
  • Treat Dataset rows as a public and economic contract. Row order, _kind, caps, and raw-output switches matter when callers build on them and each row can be billed.
  • Treat the input schema as the agent contract. The MCP server generated a useful dedicated tool from the Actor I had already published; required fields, option descriptions, and safe defaults became agent-facing behavior.
  • On pay-per-result, your defaults are a spending policy. An agent fills the required field and inherits everything else. Mine would have returned about 52 billable rows instead of 3. Default to cheap, cap the output, and put the price in the field descriptions — and remember prefill never reaches an MCP caller, only default does.
  • Keep tool metadata separate from bulk results. Returning the Dataset id first lets an agent page or project rows instead of accepting an entire scrape into its context window.
  • Adapt internal logic instead of claiming it copied cleanly. The game and Actor classifiers share mechanics but use different vocabularies, prompts, module systems, and operating contexts.
  • Say what is demonstrated, not what is plausible. The transcript proves the MCP tool exchange through a hand-written client. The Claude Desktop and Cursor block is setup guidance, not evidence that either client produced this run.

Wrap-up

Six public Store Actors absorb the collection churn on Instagram, TikTok, and YouTube. A small REST client turns their runs into Dataset pages, while the game owns normalization, classification, and operational storage. The same Dataset model supports deliberate database exports. Packaging a related workflow as ALL-IN-ONE Market Sentiment Marker gave callers a marker-first JSON contract with explicit cost controls; loading it through the Apify MCP server turned that same contract into a dedicated agent tool with no adapter code.

The companion engineering page presents the deployment's full fourteen-case register at robotgames-data-stack.pages.dev, with Apify the only provider named and six Apify cases shown across two accounts. This article shows the code paths, the account failure, the input-schema mistake, the human publication gates, and the captured MCP exchange behind them.

[AUTHOR BIO + GitHub link go here — see author-bio.md]

Top comments (0)