<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Apify</title>
    <description>The latest articles on DEV Community by Apify (apify).</description>
    <link>https://dev.to/apify</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F2171%2F8c96d506-957a-4ad7-8e96-f083077b4d3f.png</url>
      <title>DEV Community: Apify</title>
      <link>https://dev.to/apify</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/apify"/>
    <language>en</language>
    <item>
      <title>I gave my YC jobs scraper write access to Notion without ever holding a Notion token</title>
      <dc:creator>Artem Lazarev</dc:creator>
      <pubDate>Sat, 03 Oct 2026 09:06:38 +0000</pubDate>
      <link>https://dev.to/apify/i-gave-my-yc-jobs-scraper-write-access-to-notion-without-ever-holding-a-notion-token-3hkl</link>
      <guid>https://dev.to/apify/i-gave-my-yc-jobs-scraper-write-access-to-notion-without-ever-holding-a-notion-token-3hkl</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Apify MCP connectors use the Model Context Protocol (MCP) to let an Actor write into a user's connected apps without ever touching that app's credentials. I used one to push my scraped Y Combinator jobs straight into Notion and delete my CSV export step. Below: why a token field was never an option for a published Actor, the documented Python snippet that couldn't run on any SDK version, and five steps covering connector setup, tool discovery, field mapping, safe writes, and what the whole thing costs per run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow I wanted
&lt;/h2&gt;

&lt;p&gt;My Y Combinator jobs scraper has run happily for months. It walks the YC directory, pulls every company that's hiring, and returns salaries, equity ranges, founder profiles, and full job descriptions. The last full sweep did 1,429 companies and 3,305 jobs in about an hour and fifty minutes.&lt;/p&gt;

&lt;p&gt;Then the data sat there. An 18 MB JSON file in a dataset. What I actually wanted was boring and specific: a running list of who's hiring, at what salary, in which batch, that I could sort and annotate and come back to a week later. So every time, I exported a CSV, opened it in a spreadsheet, and filtered by hand. The scraper was finished. My workflow wasn't.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.apify.com/integrations/mcp-connectors" rel="noopener noreferrer"&gt;Apify MCP connectors&lt;/a&gt; closed that gap. They let an Actor call an external service's allowed tools through an Apify-managed proxy, using the user's own authorized account.&lt;/p&gt;

&lt;p&gt;The Actor is published on Apify Store, charges per result, and has 120 users I've never met.&lt;/p&gt;

&lt;p&gt;After I added connector support, it writes scraped jobs into a Notion database on its own. All it took was a connector field in the input schema, a declaration of the Notion tools it needs, and the code that turns scraped jobs into Notion pages.&lt;/p&gt;

&lt;p&gt;The full implementation is in the repo: &lt;a href="https://github.com/artem-lazarev/apify-ycombinator-jobs-scraper" rel="noopener noreferrer"&gt;github.com/artem-lazarev/apify-ycombinator-jobs-scraper&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;For users of Apify Actors.&lt;/strong&gt; To use an MCP connector, the Actor's developer has to add support for it first. Once that's in place, open &lt;strong&gt;Settings &amp;gt; API &amp;amp; Integrations&lt;/strong&gt; in your own Apify account and authorize the connector you need, such as Notion. Then open the Actor's input form, select that connector, and fill in the destination details. For my &lt;a href="https://apify.com/artemlazarevm/yc-jobs-scraper" rel="noopener noreferrer"&gt;YC Jobs Scraper&lt;/a&gt;, that's the Notion data source ID. Run it, and the scraped jobs land in your Notion database.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why I couldn't just ask for a Notion token
&lt;/h2&gt;

&lt;p&gt;The obvious option was to add a Notion API token field to the input schema and call the Notion API directly. A Notion integration token is a single secret that grants access to everything you've shared with that integration: pages, databases, all of it.&lt;/p&gt;

&lt;p&gt;Asking those users to paste a Notion token into my Actor means asking them to trust that my code won't log it or send it elsewhere. I wouldn't paste my own token into someone else's Actor, so I don't think it's reasonable to ask.&lt;/p&gt;

&lt;p&gt;MCP connectors invert that. The user authorizes Notion once, in their Apify account. My Actor receives a connector ID, which is just an opaque string. At runtime it authenticates to the Apify MCP proxy with the run's own Apify token, and the proxy adds the Notion credential server-side before forwarding anything upstream. That credential never enters my container. There's nothing for me to leak and nothing anyone has to trust me about.&lt;/p&gt;

&lt;p&gt;Users still need to check which tools an Actor declares, because it can read and write through them. Apify treats the Actor runtime as untrusted precisely because it runs developer-supplied code. I'm the untrusted party here, and I'd rather be.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need to follow along
&lt;/h2&gt;

&lt;p&gt;By the end of this walkthrough your Actor will finish a scrape and push the results into Notion on its own. No CSV export, no import step, no Notion token anywhere in your code.&lt;/p&gt;

&lt;p&gt;Before any of that works, one thing has to be true: your Notion database has to exist already, with the columns you actually want. This Actor creates pages. It doesn't create databases and it doesn't create columns. Step 3 covers what happens when you gloss over that, which in my case was a batch of jobs landing in Notion with nothing in them but titles.&lt;/p&gt;

&lt;p&gt;So build the destination first. Start with a title column and add the fields you plan to fill.&lt;/p&gt;

&lt;p&gt;The rest of the list is short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An Apify account.&lt;/li&gt;
&lt;li&gt;A Python Actor you can build and run in Apify Console.&lt;/li&gt;
&lt;li&gt;A Notion workspace you can authorize. Apify provides the managed OAuth client for Notion, so you don't need to register your own OAuth app.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apify-cli&lt;/code&gt; 1.8.0 or newer, if you deploy from the command line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The connection code targets Python 3.10 or newer, the Apify SDK, and &lt;code&gt;mcp&lt;/code&gt; 2.x. One packaging detail worth knowing up front: &lt;code&gt;mcp&lt;/code&gt; 2.x depends on &lt;code&gt;httpx2&lt;/code&gt;, which is a separate package from &lt;code&gt;httpx&lt;/code&gt;. Install and import that one.&lt;/p&gt;

&lt;p&gt;The changes land in three places: inputs go in &lt;code&gt;.actor/input_schema.json&lt;/code&gt;, the Notion helpers live in &lt;code&gt;src/notion_sync.py&lt;/code&gt;, and the sync gets called after results are saved in &lt;code&gt;src/main.py&lt;/code&gt;. The repository has the complete scraper; the excerpts below are the integration only.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Declare the Apify MCP connector and its allowed tools
&lt;/h2&gt;

&lt;p&gt;When I opened my authorized Notion connector, it offered 28 tools: &lt;code&gt;notion-search&lt;/code&gt;, &lt;code&gt;notion-update-page&lt;/code&gt;, &lt;code&gt;notion-create-database&lt;/code&gt;, and 25 more. My Actor can call two of them. The proxy rejects everything else before it ever reaches Notion.&lt;/p&gt;

&lt;p&gt;Setting up that allowlist is what this step is for, and it lives in &lt;code&gt;.actor/input_schema.json&lt;/code&gt;. Here's the &lt;code&gt;notionConnector&lt;/code&gt; field inside the existing schema's properties object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"notionConnector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Notion connector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Optionally write jobs to Notion through an authorized connector."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcpConnector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"nullable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sectionCaption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Notion output"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.notion.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="s2"&gt;"notion-create-pages"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="s2"&gt;"notion-fetch"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"notionDataSourceId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Notion data source ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Destination data source UUID or collection:// reference."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"editor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"textfield"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"nullable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;resourceType: "mcpConnector"&lt;/code&gt; turns the input into a connector picker in Console. After building the Actor, authorize Notion under &lt;strong&gt;Settings &amp;gt; API &amp;amp; Integrations&lt;/strong&gt;, then select it in the Notion output section.&lt;/p&gt;

&lt;p&gt;The second input, &lt;code&gt;notionDataSourceId&lt;/code&gt;, chooses where the pages go. Keep both optional so dataset-only runs still work. Step 3 shows how to fetch the destination reference once the connection is up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwutbvpgf8rsvsquk6zv9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwutbvpgf8rsvsquk6zv9.png" alt="Apify Console Actor input form showing the Notion MCP connector picker under a Notion output section." width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The connector picker rendered in the Actor input form, with the Notion connector selected.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;tools.required&lt;/code&gt; list is the part I'd skim past in someone else's article, so let me be specific about it. It isn't documentation and it isn't a hint. The proxy enforces it: it filters &lt;code&gt;tools/list&lt;/code&gt; down to what you declared, and rejects &lt;code&gt;tools/call&lt;/code&gt; for anything outside it.&lt;/p&gt;

&lt;p&gt;Here's the first line my Actor logs on every run, straight from a real run log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔧 Tools allowed through the connector: ['notion-create-pages', 'notion-fetch']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a jobs scraper can't rummage through your Notion. Not because I promise it won't, but because the proxy won't pass the call. You can check that yourself without reading a line of my code, which is about the only kind of security promise worth anything in a public Actor store.&lt;/p&gt;

&lt;p&gt;One thing the allowlist doesn't do: it limits which operations the Actor can perform, not which content those operations can reach. &lt;code&gt;notion-fetch&lt;/code&gt; isn't restricted to one database. What it can read still depends on the Notion account and permissions sitting behind the connector.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbd8vp1ago60e2rmwn2lw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbd8vp1ago60e2rmwn2lw.png" alt="Apify Console settings page listing an authorized Notion MCP connector." width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;MCP connectors under Settings &amp;gt; API &amp;amp; Integrations, showing the authorized Notion connector.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftjnud8vncg5afqczes6n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftjnud8vncg5afqczes6n.png" alt="Apify Console Edit connector dialog listing 28 available Notion MCP tools with read-only and idempotent annotations." width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The connector's Edit dialog, showing all 28 Notion tools it could expose.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Connect and read the tool descriptions before you write anything
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The documented snippet couldn't run on either SDK version
&lt;/h3&gt;

&lt;p&gt;The first thing I did was copy the Python snippet out of Apify's connector docs. It deployed, and then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ValueError: not enough values to unpack (expected 3, got 2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The snippet unpacked three values from the transport:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;streamable_http_client&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="nf"&gt;as &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the &lt;code&gt;mcp&lt;/code&gt; 2.x SDK, &lt;code&gt;streamable_http_client&lt;/code&gt; yields exactly two. I checked the type rather than guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.client.streamable_http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TransportStreams&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TransportStreams&lt;/span&gt;
&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ReadStream&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SessionMessage&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;WriteStream&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SessionMessage&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three values is the 1.x shape, where the third element was a session-ID callback. But 1.x has no function called &lt;code&gt;streamable_http_client&lt;/code&gt; at all. It's &lt;code&gt;streamablehttp_client&lt;/code&gt;, with no underscore between &lt;code&gt;streamable&lt;/code&gt; and &lt;code&gt;http&lt;/code&gt;, and it takes a headers argument rather than an injected HTTP client. I downloaded the 1.9.0 source to confirm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# mcp 1.9.0 - mcp/client/streamable_http.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;streamablehttp_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;sse_read_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;terminate_on_close&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the documented snippet was a hybrid: the 2.x import name with the 1.x unpacking. It couldn't run on either version. On 2.x the import resolves and the unpacking fails. On 1.x the import fails before you get that far.&lt;/p&gt;

&lt;p&gt;Apify has since fixed &lt;a href="https://docs.apify.com/integrations/mcp-connectors/use-in-actors" rel="noopener noreferrer"&gt;that page&lt;/a&gt;. It now unpacks two values, and it carries a version callout spelling out the same distinction: on &lt;code&gt;mcp&lt;/code&gt; 1.x the transport function is named &lt;code&gt;streamablehttp_client&lt;/code&gt;, takes headers instead of &lt;code&gt;http_client&lt;/code&gt;, and yields a third value. If you're reading this with a working snippet in front of you, that's why.&lt;/p&gt;

&lt;h3&gt;
  
  
  Save the tool descriptions before you build a payload
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;notion-create-pages&lt;/code&gt; description is 5,857 characters long. Console truncated it in my logs, so for the first few attempts I was coding against maybe a third of the contract.&lt;/p&gt;

&lt;p&gt;The fix is one extra block, and it's the trick I'd start with on any MCP service: save the tool descriptions and input schemas into the run's key-value store, then read them properly.&lt;/p&gt;

&lt;p&gt;Which means your first connection should do nothing but discover tools. It confirms authorization and hands you the service's real contract before you build a single payload. Each tool description explains what the tool does, and its input schema defines the arguments it accepts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Open a session from Python
&lt;/h3&gt;

&lt;p&gt;The Actor needs three values: the selected connector ID, the MCP proxy's base URL, and the Apify run token. Apify supplies the last two in every platform run.&lt;/p&gt;

&lt;p&gt;Install these in the Actor build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apify&amp;gt;=2.0.0
mcp&amp;gt;=2.0.0
httpx2&amp;gt;=2.5.0,&amp;lt;3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx2&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ClientSession&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.client.streamable_http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;streamable_http_client&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;actor_input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_input&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="n"&gt;connector_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;actor_input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;notionConnector&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;proxy_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ACTOR_MCP_CONNECTOR_BASE_URL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;connector_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;proxy_url&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;⚠️ No connector or no proxy credentials - skipping Notion sync. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;MCP connectors only resolve in a platform run, not a local one.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;httpx2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;60.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;follow_redirects&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;http_client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;streamable_http_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;proxy_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;connector_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;http_client&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;http_client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;as &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ClientSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                        &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_tools&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;
                        &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;🔧 Tools allowed through the connector: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
                            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
                        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Notion connector check failed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, immediately after the tools assignment and inside the same initialized session, add the block that saves everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;inputSchema&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NOTION_TOOL_SCHEMA&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open the run's default key-value store and read &lt;code&gt;NOTION_TOOL_SCHEMA&lt;/code&gt;. One naming detail that tripped me up: the SDK attribute is &lt;code&gt;input_schema&lt;/code&gt;, while &lt;code&gt;inputSchema&lt;/code&gt; above is just the JSON key I picked for the saved file. Inspect the allowed arguments for &lt;code&gt;notion-fetch&lt;/code&gt; and &lt;code&gt;notion-create-pages&lt;/code&gt; before going further, because the parent ID and the property formats you need in step 3 are both buried in there.&lt;/p&gt;

&lt;p&gt;Push to a dev build tag, not &lt;code&gt;latest&lt;/code&gt;. I have paying users on latest, and I wasn't going to test a new network call in front of them. There's a second reason to work this way: &lt;code&gt;ACTOR_MCP_CONNECTOR_BASE_URL&lt;/code&gt; only exists in a platform run, so &lt;code&gt;apify run&lt;/code&gt; on your laptop can't reach a connector at all. My whole loop was push to a dev tag, run on the platform, read the log.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Prepare the Notion destination and map its fields
&lt;/h2&gt;

&lt;p&gt;This is the step that cost me the most time, and the worst failure in it was the silent one.&lt;/p&gt;

&lt;p&gt;A batch of pages went into Notion with nothing but titles. No company, no salary, no equity. The log cheerfully reported that it was sending all columns. It wasn't lying, exactly: my schema parser had returned an empty dict, and the row builder read &lt;code&gt;{}&lt;/code&gt; as "this database has a title column and nothing else", so it filtered every other field out on the way through.&lt;/p&gt;

&lt;p&gt;Two things have to be right before you write anything: the destination reference, and the column map.&lt;/p&gt;

&lt;h3&gt;
  
  
  Get the data source reference
&lt;/h3&gt;

&lt;p&gt;My first mistake here was simpler. I sent the database ID, because that's what the Notion URL hands you, and the tool rejected it.&lt;/p&gt;

&lt;p&gt;A Notion database contains one or more data sources, and the write needs the ID of the source that should receive the jobs, not the database itself. A database URL on its own isn't the destination value either. Fetching the database is what makes the distinction obvious.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;database_url&lt;/code&gt; to the URL copied from your Notion database. Add this after &lt;code&gt;session.initialize()&lt;/code&gt; in the connection example, at the same indentation as the other statements inside that session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;notion-fetch&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;database_url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Notion could not fetch the database&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NOTION_DATABASE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;by_alias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;NOTION_DATABASE&lt;/code&gt; in the run's key-value store and find the &lt;code&gt;collection://&lt;/code&gt; reference for the data source you want. Put that reference, or its bare UUID, into &lt;code&gt;notionDataSourceId&lt;/code&gt;. The sync strips &lt;code&gt;collection://&lt;/code&gt; before sending &lt;code&gt;parent: {"data_source_id": source_id}&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Read the schema before building rows
&lt;/h3&gt;

&lt;p&gt;Back to the empty dict. But there's a reason I was reading the schema at all, and it wasn't the bug.&lt;/p&gt;

&lt;p&gt;I had it working against my own database and I nearly shipped that. But it needed to work for my users' databases too. My database has a column called &lt;code&gt;Salary Min&lt;/code&gt;. Theirs might call it &lt;code&gt;Salary (min)&lt;/code&gt;, or have no salary column at all. The title column is usually &lt;code&gt;Name&lt;/code&gt;, but Notion lets you rename it to anything. Since one unknown column kills an entire batch, an Actor that assumes my layout works for exactly one person.&lt;/p&gt;

&lt;p&gt;So the Actor reads the target before writing to it. That's what the second declared tool, &lt;code&gt;notion-fetch&lt;/code&gt;, is for.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;src/notion_sync.py&lt;/code&gt;, &lt;code&gt;_fetch_data_source_schema&lt;/code&gt; calls &lt;code&gt;notion-fetch&lt;/code&gt; for &lt;code&gt;source_id&lt;/code&gt; and passes the response to &lt;code&gt;parse_data_source_schema&lt;/code&gt;. Those helpers, and &lt;code&gt;logger&lt;/code&gt;, are defined in that file. The sync uses their result like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_fetch_data_source_schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;🗂️ Target columns: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;⚠️ Could not read the data source schema - sending all columns&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;or None&lt;/code&gt; is the fix for the silent failure. An empty dict is falsy, but it isn't &lt;code&gt;None&lt;/code&gt;, and the row builder treated &lt;code&gt;{}&lt;/code&gt; as a real schema with no optional columns. Collapsing it to &lt;code&gt;None&lt;/code&gt; forces a failed or empty lookup down the fallback path: send the expected fields and let Notion report the mismatch out loud. You can still end up with a rejected batch, but you'll know about it instead of finding fifty half-empty pages later. And either way the complete scraped data is already sitting in the Apify dataset.&lt;/p&gt;

&lt;p&gt;The parsing itself has a wrinkle. &lt;code&gt;notion-fetch&lt;/code&gt; doesn't return JSON. It returns a document wrapped in a JSON envelope: some prose, a &lt;code&gt;&amp;lt;data-source-state&amp;gt;&lt;/code&gt; block, and a SQLite &lt;code&gt;CREATE TABLE&lt;/code&gt; rendering of the same schema. The parser has to unwrap the envelope before it can read the schema at all. This excerpt handles the outer layer; the repository's &lt;code&gt;parse_data_source_schema&lt;/code&gt; handles the remaining formats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;envelope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the row builder sends the job title to the column with type &lt;code&gt;title&lt;/code&gt; and filters the other fields against the discovered names. This excerpt uses &lt;code&gt;_title_column&lt;/code&gt; and the candidates dictionary from &lt;code&gt;build_job_rows&lt;/code&gt; in the same file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;title_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_title_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nice side effect: point the Actor at a brand-new Notion database with only a title column and it still works. It writes titles. Add a &lt;code&gt;Salary Min&lt;/code&gt; column and the next run fills it in, with no code change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use the MCP tool's property formats
&lt;/h3&gt;

&lt;p&gt;One more trap, and this one produced the least helpful error message of the entire build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"validation_error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Properties {propertyKeys} not found in the data source.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;
            Date property {propertyKeys} not found in the data source.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;
            All editable property keys: {editableProperties}."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are literal &lt;code&gt;{propertyKeys}&lt;/code&gt; placeholders. The template never got interpolated, so the error tells you that some property is wrong while withholding which one and what the valid names are. Notion knows both. And one unknown column rejected all 20 pages in the batch, because the offending page doesn't fail on its own.&lt;/p&gt;

&lt;p&gt;Reading the saved tool description got me there faster than guessing from that message ever would have. It also handed me the encoding rules I'd otherwise have found by trial and error. Properties take scalar values rather than the Notion REST API's nested property objects. Send numbers as numbers. Checkboxes are the literal strings &lt;code&gt;__YES__&lt;/code&gt; and &lt;code&gt;__NO__&lt;/code&gt;. And date properties split across prefixed keys, so a column called "Scraped At" is written like this, where &lt;code&gt;properties&lt;/code&gt; is the row's property dictionary and &lt;code&gt;scraped_at&lt;/code&gt; is its date value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;date:Scraped At:start&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scraped_at&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not &lt;code&gt;properties['Scraped At']&lt;/code&gt;. That one would have taken me an hour on my own.&lt;/p&gt;

&lt;p&gt;Match column names exactly and give them compatible types. A Number column named &lt;code&gt;Salary Min&lt;/code&gt; will take the scraper's salary value; &lt;code&gt;Salary (min)&lt;/code&gt; gets skipped. The Actor discovers the title column, but it won't translate other names for you or validate every column type.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Save the scrape, then write pages in batches
&lt;/h2&gt;

&lt;p&gt;My first connection attempt broke, and the run still finished with exit code 0 and a complete dataset.&lt;/p&gt;

&lt;p&gt;That wasn't luck, it's the entire reason for the ordering in this step. Save the scrape to the dataset first, then sync, and keep the sync inside its own try/except. A connector failure should never throw away work the scraper has already finished, and on a per-result Actor that's work the user has already paid for.&lt;/p&gt;

&lt;p&gt;Here's the call site in &lt;code&gt;src/main.py&lt;/code&gt;, after the results are saved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Dataset first - this must succeed before anything touches Notion
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;notion_connector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;actor_input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;notionConnector&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;notion_data_source_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;actor_input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;notionDataSourceId&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;notion_connector&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;notion_data_source_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sync_jobs_to_notion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;connector_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;notion_connector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;data_source_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;notion_data_source_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;✅ Wrote &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; jobs to Notion&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Notion sync failed - dataset is unaffected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;No Notion connector selected - dataset output only&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository has the surrounding Actor lifecycle and job collection. The two guards that matter are both here: only call the sync when &lt;code&gt;notionConnector&lt;/code&gt; and &lt;code&gt;notionDataSourceId&lt;/code&gt; are both supplied, and never let it raise past the try.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check tool responses as well as exceptions
&lt;/h3&gt;

&lt;p&gt;A successful network request can still carry a failed tool result. In the Python SDK the flag is &lt;code&gt;is_error&lt;/code&gt;, snake case, not the &lt;code&gt;isError&lt;/code&gt; that most existing MCP writing shows, because most of that writing is TypeScript. Exceptions cover transport and protocol failures; &lt;code&gt;is_error&lt;/code&gt; covers everything else, and you have to check it after every call. Miss it and every write looks like it worked while nothing appears in Notion.&lt;/p&gt;

&lt;p&gt;Define this helper in &lt;code&gt;src/notion_sync.py&lt;/code&gt; before the batch-writing function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_tool_error_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;is_error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;unknown error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the session is initialized and the rows are built, call &lt;code&gt;notion-create-pages&lt;/code&gt; with the data source ID as parent. This excerpt from &lt;code&gt;sync_jobs_to_notion&lt;/code&gt; uses &lt;code&gt;_chunk&lt;/code&gt; and &lt;code&gt;PAGE_BATCH_SIZE&lt;/code&gt; (20) from the same file; &lt;code&gt;CREATE_PAGES_TOOL&lt;/code&gt; is &lt;code&gt;notion-create-pages&lt;/code&gt;, and &lt;code&gt;created&lt;/code&gt; starts at zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;batch_no&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PAGE_BATCH_SIZE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;CREATE_PAGES_TOOL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;parent&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;data_source_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;source_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pages&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;error_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_tool_error_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;error_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Notion batch &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;batch_no&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error_text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log the failed batches and count only the successful ones. And since the run can exit 0 with a broken sync, check the sync logs, not just the overall run status.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6mzypmvb3h4u137wri4l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6mzypmvb3h4u137wri4l.png" alt="Notion database of Y Combinator jobs with company, YC batch, salary, and equity columns filled in by the Apify Actor." width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The Notion database after a run, populated with YC jobs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Start with a small run and check a few pages against the dataset: job title, company, salary, destination. If your database only has a title column you'll get title-only pages, which is the same symptom as the empty-schema bug in this step with a far more boring cause. Add the optional columns before you expect to see values in them.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Verify the result and measure the overhead
&lt;/h2&gt;

&lt;p&gt;Two seconds and about 5% more compute. That's what the Notion write cost me.&lt;/p&gt;

&lt;p&gt;I ran the Actor over 20 companies at 512 MB, with and without the connector:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;Compute units&lt;/th&gt;
&lt;th&gt;Jobs&lt;/th&gt;
&lt;th&gt;Notion pages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Without connector&lt;/td&gt;
&lt;td&gt;38.7 s&lt;/td&gt;
&lt;td&gt;0.00537&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With connector&lt;/td&gt;
&lt;td&gt;40.7 s&lt;/td&gt;
&lt;td&gt;0.00566&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0r620tyjtgf3lzi8gui3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0r620tyjtgf3lzi8gui3.png" alt="Apify run log showing the MCP connector tool list, the discovered Notion columns, and fifty jobs written in three batches." width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The Apify run log showing tool discovery, the target columns, and the batch writes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Because this Actor charges per result, that extra compute comes out of my margin. It doesn't raise the per-result price my users pay. At 5% I'll take that trade happily, but it's worth knowing which side of the ledger the overhead lands on before you ship a connector on a per-result Actor.&lt;/p&gt;

&lt;p&gt;The 50 pages went out in three &lt;code&gt;notion-create-pages&lt;/code&gt; calls, batched 20, 20, and 10. Those were the writes only; tool discovery and schema fetching were additional requests. One call per job would have meant 50 round trips and a much less pleasant table. It's a small comparison, so treat it as the overhead I measured rather than a guarantee for larger runs or other services.&lt;/p&gt;

&lt;p&gt;The scrape output had the same shape in both runs: 20 companies, 50 jobs, 28 founders. The connector is optional. If nobody selects one, the Actor makes no MCP calls at all, and my existing users see exactly what they saw last month.&lt;/p&gt;

&lt;h3&gt;
  
  
  Before you schedule it, deal with duplicates
&lt;/h3&gt;

&lt;p&gt;One thing I haven't solved yet. Every run appends new pages, so a daily schedule will re-add jobs that are already in the database.&lt;/p&gt;

&lt;p&gt;There's no deduplication in the current implementation. If you want a persistent job tracker rather than a snapshot, comparing job URLs before writing is the next change to make, and I'd make it before setting up any schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I'd read from Notion, not just write to it. Right now the filters live in the Actor input. It'd be better to keep a Notion database of the batches, industries, and locations I care about, have the Actor read its own task list at the start of a run, and write results back. A loop instead of a one-way pipe. The connector already permits it; I just haven't built it.&lt;/p&gt;

&lt;p&gt;I'd also like to try two connectors on one Actor: Notion for the archive, Slack for a message when a job matches a saved search. The input schema accepts an array of connector IDs, with one MCP session per connector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reuse the Apify MCP connector pattern in another Actor
&lt;/h2&gt;

&lt;p&gt;For a different destination, keep the same order: declare the tools, connect and save their descriptions, inspect the destination, map your results, and write only once the dataset is safe.&lt;/p&gt;

&lt;p&gt;Another service will have its own connector, its own tool names, and its own payload format. Which is exactly why the discovery step in step 2 is worth doing first. It hands you those requirements up front instead of making you reverse-engineer them from error messages with &lt;code&gt;{propertyKeys}&lt;/code&gt; in them.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I add a connector to any existing Actor?
&lt;/h3&gt;

&lt;p&gt;The Actor has to declare the connector input and contain code that calls it. For this scraper, select Notion and supply &lt;code&gt;notionDataSourceId&lt;/code&gt;. Adding a connector in your account settings alone doesn't make an unrelated Actor write to Notion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can the Actor see my Notion token?
&lt;/h3&gt;

&lt;p&gt;No. It authenticates to the Apify MCP proxy with its own run token, and the proxy adds the Notion credential server-side. The connector session ends with the run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I restrict what the Actor can do?
&lt;/h3&gt;

&lt;p&gt;Yes. Access has to satisfy the service's authorization permissions, any connector-level tool allowlist, and the Actor's &lt;code&gt;mcpServers[].tools&lt;/code&gt; declaration. A tool allowlist limits operations, while the upstream service controls which content those operations can reach.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I develop this locally?
&lt;/h3&gt;

&lt;p&gt;Not really. &lt;code&gt;ACTOR_MCP_CONNECTOR_BASE_URL&lt;/code&gt; only exists in a platform run. I test row building and response parsing locally, then use a dev build to verify authorization and real tool calls.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which SDK version do I need?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;mcp&lt;/code&gt; 2.x for Python, which pulls &lt;code&gt;httpx2&lt;/code&gt; rather than &lt;code&gt;httpx&lt;/code&gt;. The 1.x API differs in both the transport function's name and its signature.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this the Apify MCP server?
&lt;/h3&gt;

&lt;p&gt;No. Here the Actor calls Notion through an outbound MCP connector. The Apify MCP server is the other direction: it lets external AI clients call Actors.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Code:&lt;/strong&gt; the connector implementation is in &lt;a href="https://github.com/artem-lazarev/apify-ycombinator-jobs-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;src/notion_sync.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Actor:&lt;/strong&gt; &lt;a href="https://apify.com/artemlazarevm/yc-jobs-scraper" rel="noopener noreferrer"&gt;YC Jobs Scraper on Apify Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.apify.com/integrations/mcp-connectors" rel="noopener noreferrer"&gt;MCP connectors overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.apify.com/integrations/mcp-connectors/use-in-actors" rel="noopener noreferrer"&gt;Build Actors with MCP connectors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.apify.com/actors/development/actor-definition/input-schema/specification/v1" rel="noopener noreferrer"&gt;Actor input schema specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification" rel="noopener noreferrer"&gt;Model Context Protocol specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/modelcontextprotocol/python-sdk" rel="noopener noreferrer"&gt;MCP Python SDK&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>automation</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Get Reliable Options Chain Data in Python (When yfinance Keeps Failing)</title>
      <dc:creator>Ahmed Jasarevic</dc:creator>
      <pubDate>Mon, 21 Sep 2026 14:57:30 +0000</pubDate>
      <link>https://dev.to/apify/how-to-get-reliable-options-chain-data-in-python-when-yfinance-keeps-failing-1g5f</link>
      <guid>https://dev.to/apify/how-to-get-reliable-options-chain-data-in-python-when-yfinance-keeps-failing-1g5f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Getting &lt;em&gt;stock prices&lt;/em&gt; in Python is easy. Getting &lt;strong&gt;options chain data&lt;/strong&gt; (strikes, expirations, implied volatility, open interest, volume) reliably and at scale is a different problem. This guide shows you how to pull clean, structured options data into a pandas DataFrame and build an options screener and IV dashboard on top of it — without fighting rate limits or rewriting your data layer every few weeks.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The problem nobody warns you about
&lt;/h2&gt;

&lt;p&gt;You've probably built this pipeline before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;yfinance&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;

&lt;span class="n"&gt;aapl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Ticker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AAPL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aapl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option_chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-10-17&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;
&lt;span class="n"&gt;puts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;puts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works beautifully in a notebook. Then you ship it.&lt;/p&gt;

&lt;p&gt;And then, three weeks later, it returns empty DataFrames. Or throws a 429. Or works for 40 tickers and silently fails for the other 460. Or your nightly job just… doesn't have data this morning.&lt;/p&gt;

&lt;p&gt;If you're building anything that depends on options data — a screener, a volatility dashboard, an alert bot, a backtest — &lt;strong&gt;the data layer is where projects die.&lt;/strong&gt; Not the strategy. Not the model. The data layer.&lt;/p&gt;

&lt;p&gt;This article is about fixing that layer once, so you can get back to building the interesting part.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why options data is much harder than stock prices
&lt;/h2&gt;

&lt;p&gt;Stock quotes are one number. Options chains are &lt;em&gt;hundreds of contracts per ticker&lt;/em&gt; that each carry their own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strike price&lt;/strong&gt; and &lt;strong&gt;expiration date&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract type&lt;/strong&gt; (call / put)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Implied volatility (IV)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open interest&lt;/strong&gt; and &lt;strong&gt;volume&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bid / ask / last price&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In-the-money status&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The underlying stock price&lt;/strong&gt; at the moment of the snapshot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a lot of surface area. And unlike a simple price feed, options data is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Large&lt;/strong&gt; — a single liquid ticker can have thousands of contracts across all expirations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time-sensitive&lt;/strong&gt; — IV and open interest shift throughout the trading day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Painful to fetch at scale&lt;/strong&gt; — you need hundreds of requests for a realistic watchlist.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most free options data sources were never designed for this. They're fine for a demo with three tickers. They fall over the moment you point them at a real universe.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the free route breaks in production
&lt;/h2&gt;

&lt;p&gt;If you've used &lt;code&gt;yfinance&lt;/code&gt; or &lt;code&gt;yahoo_fin&lt;/code&gt;, you already know the pattern. These libraries are excellent for exploration and research, and I genuinely recommend them for that. But they share a few characteristics that make production usage painful:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you need&lt;/th&gt;
&lt;th&gt;The free/DIY route&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Predictable results at scale&lt;/td&gt;
&lt;td&gt;Rate limiting appears as you scale up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consistent schema across tickers&lt;/td&gt;
&lt;td&gt;Fields shift; empty responses happen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hundreds of tickers per run&lt;/td&gt;
&lt;td&gt;You manage retries, throttling and pacing yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fresh data on a schedule&lt;/td&gt;
&lt;td&gt;Silent failures — you only notice when data is missing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero maintenance&lt;/td&gt;
&lt;td&gt;Breaks when upstream changes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The core issue isn't that these tools are bad. It's that &lt;strong&gt;they're not a data pipeline.&lt;/strong&gt; They're a convenience wrapper. When you build on top of them, you inherit 100% of the reliability burden — retries, backoff, session handling, proxy rotation, schema normalization, and monitoring.&lt;/p&gt;

&lt;p&gt;That's a full-time engineering project. And it has nothing to do with the options strategy you actually want to build.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "reliable" options data actually looks like
&lt;/h2&gt;

&lt;p&gt;Before choosing a tool, get clear on the contract. You want each row to look like this — one row per option contract:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ticker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Underlying symbol (e.g. &lt;code&gt;AAPL&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;symbol&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full options contract symbol&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;call&lt;/code&gt; or &lt;code&gt;put&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;strike&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Strike price&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expiration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expiration date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;price&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Option contract price&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stockPrice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Underlying stock price at scrape time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Implied volatility&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;volume&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Contracts traded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openInterest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open contracts outstanding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;itm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether the contract is in-the-money&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;scrapedAt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timestamp of the snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a clean, &lt;strong&gt;flattened&lt;/strong&gt; schema — one row per contract, ready for a DataFrame, ready for CSV, ready for a database, ready for a chart. No nested JSON to unpack, no per-ticker field drift.&lt;/p&gt;

&lt;p&gt;The goal is simple: &lt;strong&gt;you ask for tickers, you get back a tidy table.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting the data (in about 15 lines)
&lt;/h2&gt;

&lt;p&gt;Instead of maintaining a scraper, you can call a managed data source that returns exactly the schema above. Here's the whole integration — a plain HTTP call, so it works from Python, Node, a cron job, or an agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;APIFY_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;ACTOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ahmed_jasarevic~yahoo-finance-options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.apify.com/v2/acts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ACTOR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/run-sync-get-dataset-items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tickers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AAPL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TSLA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NVDA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limitPerTicker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; contracts across &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ticker&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;nunique&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; tickers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No retry logic, no session management, no proxy setup, no per-ticker error handling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input options:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Required&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tickers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;array&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Symbols to fetch, e.g. &lt;code&gt;["AAPL", "TSLA"]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;limitPerTicker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;&lt;code&gt;20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Number of call + put contracts per ticker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;proxyConfiguration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;object&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Apify Proxy&lt;/td&gt;
&lt;td&gt;Residential proxy recommended for larger runs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A couple of things worth knowing before you build on it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;limitPerTicker&lt;/code&gt; controls cost and speed.&lt;/strong&gt; Start at 20–50 while developing, raise it when you go live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The output is one row per contract&lt;/strong&gt;, so 50 tickers × 50 contracts = 2,500 rows. Size your DataFrame accordingly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing is per result&lt;/strong&gt; (roughly &lt;strong&gt;$1.20 per 1,000 contracts&lt;/strong&gt; at the free tier, and lower as your Apify plan tier goes up). A 10-ticker daily screener is fractions of a cent per run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also run it interactively from the Store page if you just want to eyeball the output first:&lt;br&gt;
👉 &lt;strong&gt;&lt;a href="https://apify.com/ahmed_jasarevic/yahoo-finance-options" rel="noopener noreferrer"&gt;Yahoo Options — Chains, IV, Live&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Use case 1 — Build an options screener
&lt;/h2&gt;

&lt;p&gt;Now the fun part. With a clean DataFrame, a screener is genuinely a few lines.&lt;/p&gt;

&lt;p&gt;Let's find &lt;strong&gt;liquid calls&lt;/strong&gt; — high open interest, real volume, and an IV band that filters out both dead contracts and lottery-ticket noise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="c1"&gt;# Normalise types
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coerce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openInterest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openInterest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coerce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;volume&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;volume&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coerce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;screened&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openInterest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;      &lt;span class="c1"&gt;# real liquidity
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;volume&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;            &lt;span class="c1"&gt;# actually trading today
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;      &lt;span class="c1"&gt;# avoid dead + lottery contracts
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;itm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# out-of-the-money only
&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openInterest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ascending&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;screened&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strike&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expiration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openInterest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;volume&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]].&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That table is the beginning of a real tool. From here you can add your own filters: distance from spot, days-to-expiration windows, spread width, IV rank against history — whatever your strategy needs.&lt;/p&gt;

&lt;p&gt;Because the schema is stable, &lt;strong&gt;your screener logic never has to change when the data source does.&lt;/strong&gt; That's the whole point.&lt;/p&gt;




&lt;h2&gt;
  
  
  Use case 2 — Track implied volatility over time
&lt;/h2&gt;

&lt;p&gt;IV is the single most-watched number in options. But IV only becomes &lt;em&gt;useful&lt;/em&gt; when you have history — today's IV means nothing without yesterday's to compare it to.&lt;/p&gt;

&lt;p&gt;The pattern is: &lt;strong&gt;snapshot on a schedule → store → chart.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Append each run to a CSV (or swap in SQLite / Postgres / DuckDB)
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrapedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrapedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coerce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iv_history.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then compute a simple average IV per ticker over time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iv_history.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrapedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrapedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;trend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrapedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset_index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrapedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# A 30-point IV jump on a single name is worth looking at
&lt;/span&gt;&lt;span class="n"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;nth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;spikes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sort_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ascending&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spikes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You've now got the foundation of a &lt;strong&gt;volatility alert system&lt;/strong&gt; — the thing that actually tells you when something interesting is happening in the market, instead of you refreshing a page all day.&lt;/p&gt;




&lt;h2&gt;
  
  
  Use case 3 — Automate it and stop thinking about it
&lt;/h2&gt;

&lt;p&gt;The real value kicks in when this runs without you. Two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduling:&lt;/strong&gt; Apify supports cron-style schedules, so you can run the same configuration every market day at a fixed time — pre-market, at the open, or at the close.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Piping the output anywhere:&lt;/strong&gt; Because the output is clean JSON, you can push it straight into Google Sheets, a webhook, Make/Zapier, your own database, or an AI agent that summarises what changed.&lt;/p&gt;

&lt;p&gt;A practical setup for a watchlist monitor:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Schedule&lt;/strong&gt; a run each trading morning with your ticker list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Append&lt;/strong&gt; results to your storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff&lt;/strong&gt; against yesterday's snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert&lt;/strong&gt; when IV, open interest or volume moves beyond a threshold you care about.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's a monitoring product you'd otherwise spend a month building. It's now a config file and a schedule.&lt;/p&gt;




&lt;h2&gt;
  
  
  How this compares to a DIY pipeline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;DIY free library&lt;/th&gt;
&lt;th&gt;This managed actor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Get options chains&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flattened, stable schema&lt;/td&gt;
&lt;td&gt;⚠️ varies&lt;/td&gt;
&lt;td&gt;✅ one row per contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale to hundreds of tickers&lt;/td&gt;
&lt;td&gt;⚠️ rate limits&lt;/td&gt;
&lt;td&gt;✅ built for it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retries / throttling handled&lt;/td&gt;
&lt;td&gt;❌ you build it&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs unattended on a schedule&lt;/td&gt;
&lt;td&gt;⚠️ fragile&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance when upstream changes&lt;/td&gt;
&lt;td&gt;❌ ongoing&lt;/td&gt;
&lt;td&gt;✅ none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per 1,000 contracts&lt;/td&gt;
&lt;td&gt;"free" + your time&lt;/td&gt;
&lt;td&gt;~$1.20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest framing: free libraries are great for learning and one-off research. A managed data source is what you use when &lt;strong&gt;the data needs to be there every single time&lt;/strong&gt; — because you're shipping a product on top of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is there a free Yahoo Finance options API?
&lt;/h3&gt;

&lt;p&gt;Yahoo shut down its official public API in 2017. Everything you see today — including &lt;code&gt;yfinance&lt;/code&gt; and similar libraries — relies on unofficial endpoints, which is exactly why reliability varies over time. For production use, most developers move to a managed data source rather than maintaining an unofficial one themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does yfinance return empty options data?
&lt;/h3&gt;

&lt;p&gt;It usually comes down to rate limiting, throttling, or upstream changes. When you request many tickers quickly, you can hit limits that return empty or partial results instead of a clear error — which is the worst kind of failure, because your pipeline looks healthy while quietly producing nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I get options chain data in Python?
&lt;/h3&gt;

&lt;p&gt;Two routes: use a library that wraps an unofficial source (fast to start, fragile in production), or call a managed API that returns structured JSON you load straight into a DataFrame. The second route removes the maintenance burden entirely — the snippet above is the whole integration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I get implied volatility and open interest, not just prices?
&lt;/h3&gt;

&lt;p&gt;Yes — and those are usually the fields that matter most. Implied volatility tells you what the market expects, and open interest tells you where the real money is positioned. Any useful options tool is built on those two numbers far more than on raw contract price.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does it cost to pull options data at scale?
&lt;/h3&gt;

&lt;p&gt;The actor is billed per result, roughly &lt;strong&gt;$1.20 per 1,000 contracts&lt;/strong&gt; at the free tier and cheaper at higher plan tiers. A daily 10-ticker screener at 50 contracts each is 500 contracts — well under a cent per run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use this with an AI agent or LLM?
&lt;/h3&gt;

&lt;p&gt;Yes. Because the output is structured JSON and the integration is a single HTTP call, it works cleanly as a tool for an LLM agent — useful for things like "summarise the biggest IV moves in my watchlist today."&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it work for any ticker?
&lt;/h3&gt;

&lt;p&gt;It's built for US-listed tickers with options chains. Set &lt;code&gt;tickers&lt;/code&gt; to your watchlist and run it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Options data doesn't have to be the fragile part of your project.&lt;/p&gt;

&lt;p&gt;The pattern that works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Get a stable, flattened schema&lt;/strong&gt; — one row per contract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build your logic on top of it&lt;/strong&gt; — screeners, IV tracking, alerts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule it&lt;/strong&gt; so it runs without you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never maintain a scraper again.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your edge is in what you &lt;em&gt;do&lt;/em&gt; with the data. Everything upstream of that should be boring, predictable and boring again.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Get started:&lt;/strong&gt; &lt;a href="https://apify.com/ahmed_jasarevic/yahoo-finance-options" rel="noopener noreferrer"&gt;Yahoo Options — Chains, IV, Live on Apify&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Run it once in the UI to see the output, then drop the 15-line snippet into your project and get back to building.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this saved you an afternoon of rate-limit debugging, drop a reaction — and tell me in the comments what you're building with options data. Screener, alert bot, or something weirder?&lt;/em&gt;&lt;/p&gt;




</description>
      <category>python</category>
      <category>finance</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Built a Linktree, Beacons &amp; Bio Email Scraper with Apify</title>
      <dc:creator>Ahmed Jasarevic</dc:creator>
      <pubDate>Sun, 20 Sep 2026 20:42:11 +0000</pubDate>
      <link>https://dev.to/apify/how-i-built-a-linktree-beacons-bio-email-scraper-with-apify-oo0</link>
      <guid>https://dev.to/apify/how-i-built-a-linktree-beacons-bio-email-scraper-with-apify-oo0</guid>
      <description>&lt;h2&gt;
  
  
  1. Introduction
&lt;/h2&gt;

&lt;p&gt;Collecting emails from Linktree, Beacons, and other bio links is often a tedious and time-consuming task. As a developer and freelancer working with lead generation projects, I faced this problem firsthand: manually navigating hundreds of profiles just to extract a few emails is inefficient and error-prone.&lt;br&gt;
To solve this, I built a Linktree, Beacons &amp;amp; Bio Email Scraper Actor using Apify. It automates the process, reliably collects emails from multiple platforms, and provides structured outputs for marketing, data engineering, and business intelligence purposes.&lt;br&gt;
This article details the journey: the challenges I faced, the design of the Actor, lessons learned, and tips for anyone looking to build production-grade scraping solutions.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Problem Context &amp;amp; Motivation
&lt;/h2&gt;

&lt;p&gt;Manual email collection from bio links presents several challenges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dynamic pages: Platforms like Linktree often use JavaScript to load content asynchronously. Simple HTML scraping fails here.&lt;/li&gt;
&lt;li&gt;Anti-scraping measures: Repeated requests can trigger rate limits or captchas.&lt;/li&gt;
&lt;li&gt;Scale: For real lead-generation projects, you often need hundreds or thousands of emails daily—manual collection is not an option.
I needed a solution that was fast, scalable, and reliable, and that could handle these technical challenges without breaking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Actor Overview
&lt;/h3&gt;

&lt;p&gt;Architecture and Workflow&lt;br&gt;
The Actor is built on Apify and Crawlee, combining headless browser automation with structured data extraction. Here’s a high-level workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Input: A list of Linktree, Beacons, or other bio URLs.&lt;/li&gt;
&lt;li&gt;Navigation: Actor launches a headless browser to visit each URL.&lt;/li&gt;
&lt;li&gt;Email Extraction: Uses DOM selectors and regex patterns to identify valid email addresses.&lt;/li&gt;
&lt;li&gt;Anti-blocking: Rotates proxies and applies request throttling to avoid detection.&lt;/li&gt;
&lt;li&gt;Output: Saves results in JSON or CSV with details: URL, email, timestamp.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Input, Output, and Configuration Options&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Input: List of profile URLs (CSV, JSON, or manually typed).&lt;/li&gt;
&lt;li&gt;Output: JSON, CSV, or Google Sheets integration.&lt;/li&gt;
&lt;li&gt;Configurable Options:
Maximum pages per run
Timeout per request
Proxy rotation (on/off)
Output file format
This flexibility allows the Actor to handle both small projects and large-scale campaigns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5xwioawnnjr99hm1xvbz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5xwioawnnjr99hm1xvbz.jpg" alt="Image 1: Example of output data" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Implementation Details
&lt;/h2&gt;

&lt;p&gt;Handling Dynamic Content&lt;br&gt;
Linktree and Beacons often load content via JavaScript. Initially, I tried simple HTTP requests, but emails were missing in the HTML response. Switching to Crawlee with Playwright solved the issue:&lt;/p&gt;

&lt;p&gt;import { PlaywrightCrawler } from 'crawlee';&lt;/p&gt;

&lt;p&gt;const crawler = new PlaywrightCrawler({&lt;br&gt;
    requestHandler: async ({ page, request, enqueueLinks, log }) =&amp;gt; {&lt;br&gt;
        await page.goto(request.url);&lt;br&gt;
        const emails = await page.$$eval('a[href^="mailto:"]', els =&amp;gt; els.map(e =&amp;gt; e.href));&lt;br&gt;
        console.log(&lt;code&gt;Found emails for ${request.url}: ${emails}&lt;/code&gt;);&lt;br&gt;
    }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;await crawler.run(['&lt;a href="https://linktr.ee/example'%5D" rel="noopener noreferrer"&gt;https://linktr.ee/example']&lt;/a&gt;);&lt;/p&gt;

&lt;p&gt;This approach ensures the Actor captures all visible emails, even on dynamic pages.&lt;br&gt;
Anti-Blocking Strategies&lt;br&gt;
During early tests, some accounts triggered rate limits. To fix this, I added:&lt;br&gt;
Proxy rotation&lt;br&gt;
Randomized delays between requests&lt;br&gt;
Error retries for failed pages&lt;br&gt;
These measures increased the success rate to 99.6% over thousands of URLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Metrics &amp;amp; Results
&lt;/h2&gt;

&lt;p&gt;To evaluate the Actor in a real-world scenario, I ran it against a dataset of 1,800+ URLs. The results showed strong reliability while keeping the cost of each run predictable:&lt;/p&gt;

&lt;p&gt;99.6% success rate&lt;br&gt;
1,816 emails collected&lt;br&gt;
$14.47 total cost per run&lt;br&gt;
$23.76 profit generated per run from the collected leads&lt;/p&gt;

&lt;p&gt;These results demonstrated that the Actor could process large batches of URLs reliably while remaining cost-effective for lead-generation workflows.&lt;/p&gt;

&lt;p&gt;This shows the Actor is not only technically reliable but also economically valuable for lead-generation workflows.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Lessons Learned
&lt;/h2&gt;

&lt;p&gt;Building this Actor taught me several important lessons:&lt;br&gt;
JavaScript rendering matters: Always test pages in a headless browser when dealing with dynamic content.&lt;br&gt;
Anti-blocking is critical: Even simple rotation and throttling drastically improve success rates.&lt;br&gt;
First-person debugging insights: Logging actual page content during development helped identify hidden issues.&lt;br&gt;
Scalable design: Structuring input/output for batch processing makes the Actor production-ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Who Can Benefit
&lt;/h2&gt;

&lt;p&gt;This Actor is useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Developers looking to automate repetitive data collection tasks&lt;/li&gt;
&lt;li&gt;Marketers and sales teams needing up-to-date email lists&lt;/li&gt;
&lt;li&gt;Data engineers building pipelines that integrate multiple data sources&lt;/li&gt;
&lt;li&gt;The architecture can also be adapted for other bio link platforms or email collection projects with similar challenges.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  8. Conclusion
&lt;/h3&gt;

&lt;p&gt;Automating email extraction from Linktree, Beacons, and bio links is no longer a manual nightmare. By using Apify and Crawlee, I built a reliable, scalable, and cost-effective Actor that delivers real-world results.&lt;br&gt;
If you plan to build your own Actor, remember: focus on handling dynamic content, preventing blocks, and structuring your data pipeline. Sharing these lessons ensures other developers can build production-grade automation with confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Next Steps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Test the Actor on new bio platforms&lt;/li&gt;
&lt;li&gt;Add AI-powered validation to filter incorrect emails&lt;/li&gt;
&lt;li&gt;Explore integrations with CRMs and email marketing tools&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>data</category>
      <category>scraping</category>
    </item>
    <item>
      <title>A default MCP connection hands an agent 11 tools. None of my 23 Actors is one of them.</title>
      <dc:creator>Ai-Q Labs</dc:creator>
      <pubDate>Fri, 18 Sep 2026 00:10:09 +0000</pubDate>
      <link>https://dev.to/apify/a-default-mcp-connection-hands-an-agent-11-tools-none-of-my-23-actors-is-one-of-them-65m</link>
      <guid>https://dev.to/apify/a-default-mcp-connection-hands-an-agent-11-tools-none-of-my-23-actors-is-one-of-them-65m</guid>
      <description>&lt;h1&gt;
  
  
  A default MCP connection hands an agent 11 tools. None of my 23 Actors is one of them.
&lt;/h1&gt;

&lt;p&gt;I have 23 audit Actors on the &lt;a href="https://apify.com/store" rel="noopener noreferrer"&gt;Apify Store&lt;/a&gt;. They all do a version of the same thing: take a public record, check whether what it still claims is true, and write the verdict into a dataset.&lt;/p&gt;

&lt;p&gt;I have also written six articles about what happens when an AI agent calls one of them through the &lt;a href="https://docs.apify.com/platform/integrations/mcp" rel="noopener noreferrer"&gt;Apify MCP server&lt;/a&gt;. Output fields that arrive empty. Input schemas that read differently to a form and to a tool. A &lt;code&gt;default&lt;/code&gt; that quietly fills a &lt;code&gt;required&lt;/code&gt; field. A connector that shows 44 tools and grants four.&lt;/p&gt;

&lt;p&gt;MCP is the &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;, the interface that turns an Actor into a tool that clients like Claude and Cursor can call on their own. Every one of those six articles assumed the agent had that tool in hand. Last week I checked the assumption for the first time. It does not hold by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The connection string I never read
&lt;/h2&gt;

&lt;p&gt;I found the problem in my own machine before I found it anywhere else. This is the Apify entry in my Claude config, verbatim except that there is no token in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.apify.com?tools=aiqlabs/github-repository-audit"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote that line months ago to test one Actor in isolation, and then left it. My editor session had five Apify tools in it. One of them was an Actor, and it was the one named in that string.&lt;/p&gt;

&lt;p&gt;So I had spent six articles reasoning about how agents see my catalogue. The session I was reasoning from had been narrowed to a single Actor, by me, on purpose, and then forgotten. The first thing worth measuring was what a connection with nothing in it returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a default connection actually sends
&lt;/h2&gt;

&lt;p&gt;Twenty lines, no dependencies. This is the whole test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// reach.mjs — what does a default connection to the Apify MCP server actually send?&lt;/span&gt;
&lt;span class="c1"&gt;// Run: APIFY_TOKEN=... node reach.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://mcp.apify.com/?token=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HEAD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json, text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sid&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;HEAD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp-session-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HEAD&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// the server answers as SSE, so the JSON sits on a "data:" line&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp-session-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;jsonrpc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;initialize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;protocolVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2024-11-05&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;clientInfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reach&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;jsonrpc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifications/initialized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;jsonrpc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tools/list&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`tools: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its output, on 12 August:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tools: 11
  search-actors
  fetch-actor-details
  call-actor
  get-actor-run
  get-dataset-items
  get-key-value-store-record
  abort-actor-run
  search-apify-docs
  fetch-apify-docs
  report-problem
  apify--rag-web-browser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten of those are the server's own tools. One is an Actor: &lt;a href="https://apify.com/apify/rag-web-browser" rel="noopener noreferrer"&gt;RAG Web Browser&lt;/a&gt;, which belongs to Apify. &lt;strong&gt;Of the 23 Actors I have published, zero are in the list.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nothing is being hidden. Add &lt;code&gt;?actors=&lt;/code&gt; to the same URL and name all 23 slugs. The same server then returns &lt;strong&gt;27&lt;/strong&gt; tools: my 23 plus four storage tools. The Actors are reachable. They are not default.&lt;/p&gt;

&lt;p&gt;The default is also defensible. The Store holds thousands of Actors. A server that turned every one of them into a tool definition would blow out the context window of every client that connected. Eleven tools is a budget, not a snub.&lt;/p&gt;

&lt;p&gt;But it does mean that everything I had written was conditional on a step I had never measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways an Actor reaches an agent
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Who decides&lt;/th&gt;
&lt;th&gt;What an author can do about it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;A&lt;/strong&gt; — named in the connection URL (&lt;code&gt;?actors=&lt;/code&gt; / &lt;code&gt;?tools=&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;whoever configures the agent&lt;/td&gt;
&lt;td&gt;Almost nothing. They have to know your slug before they can type it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;B&lt;/strong&gt; — included in the default set&lt;/td&gt;
&lt;td&gt;Apify&lt;/td&gt;
&lt;td&gt;Nothing. Today that set contains one Actor.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;C&lt;/strong&gt; — found at runtime by &lt;code&gt;search-actors&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Store search ranking&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;This one.&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A and B are not work I can do. C is the entire surface, so I measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the third route
&lt;/h2&gt;

&lt;p&gt;The first two attempts failed on the parameter name. &lt;code&gt;{"search": …}&lt;/code&gt; and &lt;code&gt;{"query": …}&lt;/code&gt; are both accepted and both silently ignored. The server echoes &lt;code&gt;Search query:&lt;/code&gt; back empty and returns its default list. Rather than guess a third time, I read the tool's own &lt;code&gt;inputSchema&lt;/code&gt; out of the &lt;a href="https://www.jsonrpc.org/specification" rel="noopener noreferrer"&gt;JSON-RPC&lt;/a&gt; reply to &lt;code&gt;tools/list&lt;/code&gt; above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;keywords : string   default ""
limit    : integer  default 5, max 10
offset   : integer  default 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parameter is &lt;code&gt;keywords&lt;/code&gt;. But the line that matters more is &lt;code&gt;limit&lt;/code&gt;. &lt;strong&gt;It defaults to 5 and caps at 10.&lt;/strong&gt; That is the window. An agent that runs one search and picks from it is choosing among five Actors out of the whole Store.&lt;/p&gt;

&lt;p&gt;The second trap is in the response. Each result set is introduced by a line like &lt;code&gt;**Number of Actors found:** 7&lt;/code&gt;, which reads as a total and is not one. It is the size of that page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Instagram&lt;/code&gt; keeps producing fresh results at &lt;code&gt;offset&lt;/code&gt; 0, 10, 20 and 30 — &lt;strong&gt;39 distinct Actors&lt;/strong&gt;, no repeats. One of those four pages came back with nine rather than ten, which is the second thing to know: the pages are ragged.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Chrome extensions&lt;/code&gt; returns &lt;strong&gt;7&lt;/strong&gt; at &lt;code&gt;offset&lt;/code&gt; 0 and a &lt;strong&gt;different 6&lt;/strong&gt; at &lt;code&gt;offset&lt;/code&gt; 10. A short page does not mean the list ended.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My first pass treated the short page as the end of the list, so it never looked past page one. It reported seven of my Actors as absent when they were sitting at ranks 12, 16, 31, 41, 52, 55 and 57. Every number below comes from the corrected run. That run pages to rank 100 whatever the page count says, and computes rank as &lt;code&gt;offset + position in page&lt;/code&gt;, because the pages come back ragged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it found
&lt;/h2&gt;

&lt;p&gt;I paired each of 23 Actors with the query it was built for — 28 queries in all, since several Actors got both a specific phrase and the broader term a user might type instead. Then I asked how deep in the results that Actor sits.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where my Actor lands&lt;/th&gt;
&lt;th&gt;queries&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;inside the &lt;strong&gt;default 5&lt;/strong&gt; the agent receives&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;inside the &lt;strong&gt;maximum 10&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rank 11–100: indexed, past the window&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;not in the first 100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;50%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the queries these Actors were written for, an agent doing a default search finds one of them about one time in five. Half of them are not in the first hundred results at all.&lt;/p&gt;

&lt;p&gt;That was worse than I expected, but it was not the finding. The finding is which half.&lt;/p&gt;

&lt;h2&gt;
  
  
  The queries I can win are the queries nobody is making
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;search-actors&lt;/code&gt; result carries the Actor's usage with it, in a line like &lt;code&gt;**Stats:** 20 total users, 3 monthly users&lt;/code&gt;. So the demand on a query is readable from the same response that gives you the ranking: take the 30-day user count of whatever sits at rank 1.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;my rank&lt;/th&gt;
&lt;th&gt;queries&lt;/th&gt;
&lt;th&gt;median 30-day users of that query's rank-1 Actor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;reachable (top 10)&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unreachable (past 10, or absent)&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The unreachable side is where the users are. &lt;code&gt;App Store&lt;/code&gt; — 311. &lt;code&gt;Google Play&lt;/code&gt; — 295. &lt;code&gt;article extractor&lt;/code&gt; — 271. &lt;code&gt;Shopify&lt;/code&gt; — 186. My Actors for the first three of those are not in the first hundred results.&lt;/p&gt;

&lt;p&gt;And the one query where I rank &lt;strong&gt;#1&lt;/strong&gt; is &lt;code&gt;sitemap checker&lt;/code&gt;, where the rank-1 Actor has &lt;strong&gt;0&lt;/strong&gt; users in the last thirty days. That Actor is &lt;a href="https://apify.com/aiqlabs/sitemap-checker" rel="noopener noreferrer"&gt;mine&lt;/a&gt;. I am first on a shelf with no traffic, and invisible on every shelf with traffic.&lt;/p&gt;

&lt;p&gt;I had measured the same shape once before, from the other side. On 2 August I sampled the Store itself and found that Actors naming a well-known platform had a median of 5 monthly users against 2 for the rest. I read that as an argument for naming platforms. This measurement says the platform-named shelves are exactly the ones where I cannot be seen — 1 reachable query out of 14, against 6 out of 14 for the generic ones. Both results are true and they are not in conflict: platform names are where demand collects, demand attracts competitors, and competitors fill a window five deep. But I predicted the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool tells the agent to broaden the search, and broadening removes me
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;search-actors&lt;/code&gt; response ends with this, addressed to the agent:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;IMPORTANT: You MUST always do a second search with broader, more generic keywords (e.g., just the platform name like "TikTok" instead of "TikTok posts") to make sure you haven't missed a better Actor.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is good advice for the user. It is also an instruction to run the query I do worst on. Same Actor, specific phrase against the broadened phrase:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;specific&lt;/th&gt;
&lt;th&gt;rank&lt;/th&gt;
&lt;th&gt;broadened&lt;/th&gt;
&lt;th&gt;rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sitemap checker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sitemap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not in first 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PDF tables&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;#2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PDF&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not in first 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PDF text markdown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;#2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PDF&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not in first 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Chrome extensions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;#16&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Chrome Web Store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not in first 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Shopify apps&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;#41&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Shopify&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not in first 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GitHub repository&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;#52&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GitHub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not in first 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;App Store apps&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;App Store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Play apps&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Google Play&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six worse, none better, two unchanged. The clearest one is the first: &lt;code&gt;sitemap checker&lt;/code&gt; puts me at #1, and deleting one word puts me past rank 100. Whatever advantage a precise name buys, the agent is under instructions to take a second look without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What decides the order
&lt;/h2&gt;

&lt;p&gt;Not popularity, at least not alone. Across every first page I collected, 157 of 238 adjacent pairs are in descending order of monthly users — 66%. A pure popularity sort would be 100%. Relevance carries real weight, which is how an Actor with no users reaches #1 on &lt;code&gt;sitemap checker&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Apify documents what does decide it, and says so for both surfaces at once. From &lt;a href="https://docs.apify.com/actors/publishing/quality-score" rel="noopener noreferrer"&gt;Actor quality score&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Actors with higher quality scores tend to rank higher on both surfaces, though no specific position is guaranteed.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The second surface named on that page is &lt;em&gt;"the Apify MCP server &lt;code&gt;search-actors&lt;/code&gt; tool used by external AI agents"&lt;/em&gt;. The score aggregates eight categories: reliability, popularity, feedback and community, ease of use, pricing transparency, trustworthiness, history of success, and congruency of texts.&lt;/p&gt;

&lt;p&gt;I have moved it once and can report the size of the move. On 2 August I added dataset output schemas across the catalogue; the score on the Actor I watched went from 74 to 78, and after that pass the whole catalogue sat between 78 and 81. There is no API for the number — I tried four endpoints and got 404 from each — so the only place to read it is &lt;strong&gt;Console &amp;gt; Insights &amp;gt; Actor quality&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is that panel today, for the Actor this article keeps returning to:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5lo3f2qhv0gtzt3rc3gr.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5lo3f2qhv0gtzt3rc3gr.jpg" alt="Apify Console's Actor quality panel for aiqlabs/sitemap-checker. The score reads 79 out of 100, with a badge saying " width="799" height="344"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;79 out of 100. Better than 71% of Actors on the platform, better than 99% on reliability, and one suggestion left in the panel.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is the pairing worth sitting with. Console rates this Actor above 71% of the platform and has one cosmetic item left to suggest. The same Actor is absent from the first hundred results on every query with real demand behind it, and its one first place is on a query with none. Both readings are correct, and the documentation is careful enough to allow it: a higher score &lt;em&gt;tends&lt;/em&gt; to rank higher, with no position guaranteed. On a crowded shelf, tending is not the same as landing in the five results the agent receives.&lt;/p&gt;

&lt;p&gt;Which leaves the honest part. Three of the eight categories — popularity, feedback and community, history of success — are things you get from having users. For an Actor with none, part of the ranking that would bring users is held shut by not having them yet. That is not a complaint about the design; a store that ranked unproven tools first would be worse for everyone using it. But it is the actual shape of the problem, and no amount of description polishing changes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell an Actor author
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check what your connection sends before you tune anything.&lt;/strong&gt; The 20 lines above answer it. If your Actor is not in the list, none of your tool-definition work is reaching an agent yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure your rank on the query you were built for&lt;/strong&gt;, with &lt;code&gt;limit: 10&lt;/code&gt;, paging past the first short page. Anything past rank 10 is in the index and outside the window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the demand off the same response.&lt;/strong&gt; The rank-1 Actor's monthly users are printed next to it. If that number is 0, being #1 buys nothing — I have the receipt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect the broad term to be worse.&lt;/strong&gt; It is also where the demand is. Decide which of those two facts you are building for, rather than discovering the trade after you publish.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I got wrong
&lt;/h2&gt;

&lt;p&gt;Three things, in the order I found them.&lt;/p&gt;

&lt;p&gt;I narrowed my own MCP connection to one Actor and then wrote six articles about how agents see my catalogue. The narrowing was in a config file I had not opened in months.&lt;/p&gt;

&lt;p&gt;I read a page count as a total, and my first pass reported seven Actors as unreachable that rank between 12 and 57. Had I not checked whether &lt;code&gt;offset&lt;/code&gt; kept returning results, this article would have carried seven false claims of the most flattering kind — the kind where the platform looks worse than it is.&lt;/p&gt;

&lt;p&gt;And I expected the platform-named Actors to be the findable ones. They are the least findable ones I own.&lt;/p&gt;

&lt;p&gt;None of that makes the earlier six articles wrong. It reorders them. An input schema that misleads an agent, an output schema that arrives empty, a &lt;code&gt;default&lt;/code&gt; that fills a &lt;code&gt;required&lt;/code&gt; field — all of those are real, and all of them start to matter at the moment the agent receives the tool. For 23 out of 23 of mine, that moment does not happen by default.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>llm</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I pinned my Actors to the MCP URL, the way the docs recommend. The agent lost every way to see what a call costs.</title>
      <dc:creator>Ai-Q Labs</dc:creator>
      <pubDate>Fri, 18 Sep 2026 00:09:29 +0000</pubDate>
      <link>https://dev.to/apify/i-pinned-my-actors-to-the-mcp-url-the-way-the-docs-recommend-the-agent-lost-every-way-to-see-what-529h</link>
      <guid>https://dev.to/apify/i-pinned-my-actors-to-the-mcp-url-the-way-the-docs-recommend-the-agent-lost-every-way-to-see-what-529h</guid>
      <description>&lt;h1&gt;
  
  
  I pinned my Actors to the MCP URL, the way the docs recommend. The agent lost every way to see what a call costs.
&lt;/h1&gt;

&lt;p&gt;I publish 23 Actors on the Apify Store. All 23 are pay-per-event.&lt;/p&gt;

&lt;p&gt;Last week I measured how an AI agent actually reaches them. The answer was uncomfortable. A&lt;br&gt;
default MCP connection hands an agent 11 tools. None of mine is one of them.&lt;/p&gt;

&lt;p&gt;So you pin. You name your Actors in the connection URL. The&lt;br&gt;
&lt;a href="https://docs.apify.com/platform/integrations/mcp" rel="noopener noreferrer"&gt;Apify MCP documentation&lt;/a&gt; tells you to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For production deployments, explicitly specify which tools to load rather than relying on&lt;br&gt;
defaults. This ensures consistent behavior across updates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is good advice for reliability. I followed it. Then I measured what the agent receives&lt;br&gt;
afterwards, and found something the docs do not mention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pinning removes the agent's ability to see what a call costs. It also removes its ability to&lt;br&gt;
cap what a call spends.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Below are the measurements. Then the three tools that disappear. Then the half of this that is&lt;br&gt;
my own fault rather than the platform's.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39kpo9erq6v4purd8d3h.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39kpo9erq6v4purd8d3h.jpg" alt="Two tool lists side by side. The default connection shows 11 tools. The same account pinned to two Actors shows 6. call-actor, search-actors and fetch-actor-details appear only on the left." width="800" height="357"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What pinning actually changes
&lt;/h2&gt;

&lt;p&gt;I connected three ways and listed the tools each time. Same account, same day, 2026-08-12.&lt;br&gt;
Every listing came from a &lt;code&gt;tools/list&lt;/code&gt; call over the&lt;br&gt;
&lt;a href="https://modelcontextprotocol.io/specification" rel="noopener noreferrer"&gt;MCP HTTP transport&lt;/a&gt;. That is what the client&lt;br&gt;
library hands the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default connection&lt;/strong&gt;, no parameters. Eleven tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;search-actors          get-key-value-store-record   report-problem
fetch-actor-details    abort-actor-run              apify--rag-web-browser
call-actor             search-apify-docs
get-actor-run          fetch-apify-docs
get-dataset-items
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pinned with two of my Actors&lt;/strong&gt;, using the documented parameter&lt;br&gt;
&lt;code&gt;?tools=aiqlabs/seo-audit-tool,aiqlabs/pdf-inspector&lt;/code&gt;. Six tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aiqlabs--seo-audit-tool    get-actor-run                 abort-actor-run
aiqlabs--pdf-inspector     get-dataset-items
                           get-key-value-store-record
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pinned with all 23.&lt;/strong&gt; Twenty-seven tools: my 23, plus the same four.&lt;/p&gt;

&lt;p&gt;The four survivors are all post-run tools. Each one reads a run, reads a dataset, reads a&lt;br&gt;
key-value record, or aborts something already running.&lt;/p&gt;

&lt;p&gt;Every tool that helps an agent decide &lt;em&gt;before&lt;/em&gt; it calls is gone. Three of the missing ones&lt;br&gt;
matter for money.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three tools that carried the price
&lt;/h2&gt;

&lt;p&gt;I searched the full JSON of each default tool definition for pricing language. These are the&lt;br&gt;
strings that came back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;search-actors&lt;/code&gt;&lt;/strong&gt; returns pricing in its results. Its own description says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- **Pricing:** Details with pricing link
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its output schema enumerates the models. &lt;code&gt;FREE&lt;/code&gt;, &lt;code&gt;PRICE_PER_DATASET_ITEM&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;FLAT_PRICE_PER_MONTH&lt;/code&gt;, and a &lt;code&gt;pricePerUnit&lt;/code&gt; field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;fetch-actor-details&lt;/code&gt;&lt;/strong&gt; takes a flag whose description reads, in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Include pricing model and costs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;call-actor&lt;/code&gt;&lt;/strong&gt; is the one that actually protects the caller. It accepts &lt;code&gt;maxTotalChargeUsd&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pay-per-event Actors only — ignored otherwise.
Caps total USD billed; does NOT limit work. Prefer the Actor's own input fields to bound work.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a spending ceiling. The caller sets it, at call time. Note the second sentence. I will&lt;br&gt;
come back to it, because it is the sentence that indicts me.&lt;/p&gt;

&lt;p&gt;All three tools are in the default set. &lt;strong&gt;None of them survives pinning.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An agent holding my Actors directly cannot look up what they cost. It cannot put a ceiling on&lt;br&gt;
what it spends. It can only call them.&lt;/p&gt;
&lt;h2&gt;
  
  
  What my own definitions tell the agent about price
&lt;/h2&gt;

&lt;p&gt;Nothing. I checked, and I checked wrong the first time.&lt;/p&gt;

&lt;p&gt;My first pass searched each tool definition for a dollar sign. It reported a hit on 23 of 23.&lt;br&gt;
For about a minute I believed my descriptions already carried prices.&lt;/p&gt;

&lt;p&gt;They do not. The dollar signs were &lt;code&gt;$id&lt;/code&gt;, the JSON Schema keyword:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"required":[],"$id":"https://apify.com/mcp/aiqlabs--app-store-audit"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I searched again for a dollar sign followed by a digit. An actual amount. The count was&lt;br&gt;
&lt;strong&gt;0 of 23&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The words &lt;code&gt;price&lt;/code&gt; and &lt;code&gt;cost&lt;/code&gt; do appear in a few of my definitions. Every one of them is about&lt;br&gt;
the subject matter, not the invoice.&lt;/p&gt;

&lt;p&gt;One Actor notes that prices and availability differ by storefront. Another explains that each&lt;br&gt;
archived URL costs a request to the live site.&lt;/p&gt;

&lt;p&gt;Those are useful sentences. Neither tells an agent what calling the tool will cost.&lt;/p&gt;

&lt;p&gt;Meanwhile every one of the 23 is metered. Each has two charge events, configured the way&lt;br&gt;
&lt;a href="https://docs.apify.com/platform/actors/publishing/monetize" rel="noopener noreferrer"&gt;Apify's monetization docs&lt;/a&gt;&lt;br&gt;
describe:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apify-actor-start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.00005&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apify-default-dataset-item&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.002 – $0.01 depending on the Actor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The per-item price is the one that moves.&lt;/p&gt;

&lt;p&gt;I wanted to be sure those events fire on their own. My Actors never call a charge function. So&lt;br&gt;
I read a real run record rather than assuming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;run thLehE7kk3NVRAvqm  SUCCEEDED
chargedEventCounts: {"apify-actor-start":4,"apify-default-dataset-item":608}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They fire. Another run in the same batch recorded 1,000 dataset items. At my highest per-item&lt;br&gt;
price, one call like that is ten dollars.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg5zy4epb2x9lnbb21dck.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg5zy4epb2x9lnbb21dck.jpg" alt="The public pricing page for one of my Actors, showing the two configured charge events: $5.00 per 1,000 results and $0.00005 per Actor start." width="800" height="357"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that is my fault
&lt;/h2&gt;

&lt;p&gt;Go back to that sentence in &lt;code&gt;call-actor&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Prefer the Actor's own input fields to bound work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Apify is right about this. A billing cap stops the invoice, not the work. The real bound&lt;br&gt;
belongs in the&lt;br&gt;
&lt;a href="https://docs.apify.com/platform/actors/development/actor-definition/input-schema" rel="noopener noreferrer"&gt;input schema&lt;/a&gt;,&lt;br&gt;
where the author controls it.&lt;/p&gt;

&lt;p&gt;So I checked whether my own Actors carry that bound.&lt;/p&gt;

&lt;p&gt;All 23 have a field for it. &lt;code&gt;maxUrls&lt;/code&gt;, &lt;code&gt;maxPages&lt;/code&gt;, &lt;code&gt;maxApps&lt;/code&gt;, &lt;code&gt;maxPdfs&lt;/code&gt;, &lt;code&gt;maxVehicles&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;maxOrganizations&lt;/code&gt;, &lt;code&gt;maxStories&lt;/code&gt;. Twenty-three out of twenty-three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not one of them declares a JSON Schema &lt;code&gt;maximum&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pdf-inspector        maxPages(none), maxPdfs(none), maxFileMb(none), maxLinksToCheck(none)
sitemap-checker      maxUrlsToCheck(none), maxSitemaps(none), maxDepth(none)
http-status-checker  maxUrls(none), maxRedirects(none), maxConcurrency(none)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I named the fields as if they were limits. I never made them limits.&lt;/p&gt;

&lt;p&gt;An agent can pass &lt;code&gt;maxUrls: 1000000&lt;/code&gt;. The schema will accept it. I told the schema that any&lt;br&gt;
integer is fine.&lt;/p&gt;

&lt;p&gt;So the failure has two halves. Only one of them is the platform's.&lt;/p&gt;

&lt;p&gt;Pinning removes the caller-side ceiling. That is the platform's shape. My Actors have no&lt;br&gt;
author-side ceiling. That is mine, in 23 files I wrote myself.&lt;/p&gt;

&lt;p&gt;An agent holding my pinned tools has no cap available from either direction.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I got wrong on the way here
&lt;/h2&gt;

&lt;p&gt;I started this measurement expecting a different problem.&lt;/p&gt;

&lt;p&gt;My hypothesis was &lt;strong&gt;time&lt;/strong&gt;. Actor runs are slow. Agents time out waiting. Authors ship tools an&lt;br&gt;
agent cannot practically await.&lt;/p&gt;

&lt;p&gt;I pulled the run history for all 23 and computed durations from the API.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Median of per-Actor medians&lt;/td&gt;
&lt;td&gt;4.05 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slowest single run observed&lt;/td&gt;
&lt;td&gt;58.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actors with a median above 60 s&lt;/td&gt;
&lt;td&gt;0 of 23&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There is no timeout problem. My hypothesis was wrong.&lt;/p&gt;

&lt;p&gt;I dropped that half of the article rather than stretching the numbers to fit it. Only five of&lt;br&gt;
the 23 had enough run history to produce a median, and that limit is worth stating. But nothing&lt;br&gt;
in the data pointed toward latency. I would rather report a dead hypothesis than a decorated&lt;br&gt;
one.&lt;/p&gt;
&lt;h2&gt;
  
  
  What to change, concretely
&lt;/h2&gt;

&lt;p&gt;Three changes. The first two are the author's job. The third is a question for the platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Put a real ceiling in the input schema.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A field named &lt;code&gt;maxPages&lt;/code&gt; should refuse an absurd value. The&lt;br&gt;
&lt;a href="https://json-schema.org/understanding-json-schema/reference/numeric" rel="noopener noreferrer"&gt;&lt;code&gt;maximum&lt;/code&gt; keyword&lt;/a&gt; does&lt;br&gt;
this before your code runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"maxPages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Max pages per PDF"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minimum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maximum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hard cap. Each page produces one billed result row."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;default&lt;/code&gt; is not a limit. &lt;code&gt;prefill&lt;/code&gt; is not a limit either, and the two are easy to confuse in&lt;br&gt;
Console. That distinction bit me once already, from the correctness side. This is the same&lt;br&gt;
lesson arriving from the money side.&lt;/p&gt;

&lt;p&gt;You can verify the deployed schema rather than trusting the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.apify.com/v2/acts/&amp;lt;user&amp;gt;~&amp;lt;actor&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'"maximum":[0-9]*'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Say the price in the description the agent reads.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once you pin, the agent has no other source. One clause is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Each result row is one billed event ($0.002). A 500-URL input bills roughly $1.00.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sentence costs you nothing. It is the only pricing signal a pinned agent will ever see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The platform question.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;call-actor&lt;/code&gt; exists in the default set and carries &lt;code&gt;maxTotalChargeUsd&lt;/code&gt;. Pinned tools are&lt;br&gt;
invoked directly, so no wrapper carries it.&lt;/p&gt;

&lt;p&gt;A per-call spending cap that survived pinning would close the gap. It could be an optional&lt;br&gt;
argument on every metered tool, or a connection-level parameter. Either way it would spare each&lt;br&gt;
author from rediscovering this alone.&lt;/p&gt;

&lt;p&gt;I do not know whether that is on anyone's roadmap. I am reporting the shape I measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves an Actor author
&lt;/h2&gt;

&lt;p&gt;Two connection modes, and a cost to each.&lt;/p&gt;

&lt;p&gt;Leave the default. The agent can search, can read your pricing, can cap its spend. It will also&lt;br&gt;
almost certainly never receive your Actor. The default set is 11 tools and none of them is&lt;br&gt;
yours.&lt;/p&gt;

&lt;p&gt;Pin your Actors. The agent receives them and calls them directly. It does so without a price&lt;br&gt;
and without a ceiling.&lt;/p&gt;

&lt;p&gt;I do not think either mode is wrong. I think the second one quietly moves a responsibility onto&lt;br&gt;
the Actor author. The docs that recommend it do not mention the move.&lt;/p&gt;

&lt;p&gt;Until they do, the fix is the schema you already control. Mine was missing it in all 23 files.&lt;br&gt;
I only found out because I went looking for a different problem entirely.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Measurements were taken on 2026-08-12&lt;/strong&gt; against &lt;code&gt;mcp.apify.com&lt;/code&gt; with a single account. Tool&lt;br&gt;
listings came from &lt;code&gt;tools/list&lt;/code&gt; over the MCP HTTP transport. Run durations and charge counts&lt;br&gt;
came from&lt;br&gt;
&lt;a href="https://docs.apify.com/api/v2/actor-run-get" rel="noopener noreferrer"&gt;Apify API run records&lt;/a&gt;. No Actor was executed to&lt;br&gt;
produce these numbers, so nothing here cost anything to measure.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I removed the under maintenance label from my Actor twice. It came back in four hours, then in one.</title>
      <dc:creator>Ai-Q Labs</dc:creator>
      <pubDate>Fri, 18 Sep 2026 00:08:48 +0000</pubDate>
      <link>https://dev.to/apify/i-removed-the-under-maintenance-label-from-my-actor-twice-it-came-back-in-four-hours-then-in-one-48ge</link>
      <guid>https://dev.to/apify/i-removed-the-under-maintenance-label-from-my-actor-twice-it-came-back-in-four-hours-then-in-one-48ge</guid>
      <description>&lt;h1&gt;
  
  
  I removed the under maintenance label from my Actor twice. It came back in four hours, then in one.
&lt;/h1&gt;

&lt;p&gt;I publish 23 Actors on Apify. Twenty-two of them take a public record and check whether what it&lt;br&gt;
still claims is true.&lt;/p&gt;

&lt;p&gt;The twenty-third is different. It reads an audit dataset and opens a GitHub issue for each finding.&lt;br&gt;
It reaches GitHub through an &lt;a href="https://blog.apify.com/announcing-mcp-connectors/" rel="noopener noreferrer"&gt;Apify MCP connector&lt;/a&gt;,&lt;br&gt;
so the Actor never handles my token.&lt;/p&gt;

&lt;p&gt;That difference is the whole story. Two days after I published it, Apify flagged it as under&lt;br&gt;
maintenance. I spent an afternoon removing the label by hand and learned that the label is not a&lt;br&gt;
setting. It is a conclusion.&lt;/p&gt;
&lt;h2&gt;
  
  
  The email
&lt;/h2&gt;

&lt;p&gt;The notification arrived at 10:08 on 8 August. My Actor had failed the automated tests.&lt;/p&gt;

&lt;p&gt;Apify runs &lt;a href="https://docs.apify.com/platform/actors/publishing/test" rel="noopener noreferrer"&gt;a daily test on every Actor&lt;/a&gt;.&lt;br&gt;
The documentation is precise about what it does:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The test runs the Actor with its default input (defined by the prefill option in the input schema&lt;br&gt;
file) and expects it to finish with a Succeeded status and non-empty default dataset within 5&lt;br&gt;
minutes of the beginning of the run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My Actor cannot do that. Not on a bad day — on any day.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this Actor and not the other 22
&lt;/h2&gt;

&lt;p&gt;My other Actors take a URL, a domain, or a package name. Every one of those is a string. A string&lt;br&gt;
goes in the &lt;code&gt;prefill&lt;/code&gt; of the input schema, the test picks it up, and the run succeeds.&lt;/p&gt;

&lt;p&gt;An authorized connector is not a string. It is a grant, tied to an account, held by the platform.&lt;br&gt;
There is no value I can put in &lt;code&gt;prefill&lt;/code&gt; that gives the test account access to my GitHub&lt;br&gt;
installation. Nor should there be.&lt;/p&gt;

&lt;p&gt;So the run fails at the first step, every day, forever.&lt;/p&gt;

&lt;p&gt;The documentation says this outright, in a section I had not read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Actors that require some sort of authentication will always fail the tests despite being fully&lt;br&gt;
functional.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnh8wh85veus04pd0qprp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnh8wh85veus04pd0qprp.png" alt="Apify documentation section titled What if my Actor cannot comply with the test logic, stating that Actors requiring authentication will always fail the tests despite being fully functional, and directing developers to contact support" width="685" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I want to be exact about what this means, because I got it wrong at first. I had assumed the cost&lt;br&gt;
belonged to my design choice of failing fast on empty input. It does not. Twenty-two of my Actors&lt;br&gt;
fail fast on empty input and none of them were flagged. The cost belongs to a narrower category:&lt;br&gt;
&lt;strong&gt;Actors whose only successful path requires a credential the test account cannot hold.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If that describes your Actor, the daily test is not a quality signal about your code. It is a&lt;br&gt;
structural mismatch, and it will not resolve itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I did instead of reading the docs
&lt;/h2&gt;

&lt;p&gt;I went to Console. Under Publication → Display information there is an Actor status control with an&lt;br&gt;
"Under maintenance" switch. I turned it off and saved. The banner disappeared. The public page&lt;br&gt;
stopped showing the badge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmai7jqefzmgp6qp9myr9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmai7jqefzmgp6qp9myr9.png" alt="Apify Console Actor status section with three toggles, Custom status, Under maintenance and Deprecated, all switched off" width="740" height="217"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I recorded it as fixed. It was not fixed. It was hidden.&lt;/p&gt;

&lt;p&gt;At 17:08 the same day, about four hours after I cleared it, the second notification arrived. The&lt;br&gt;
label was back.&lt;/p&gt;

&lt;p&gt;I removed it again. At 18:08 — inside an hour — the third notification arrived.&lt;/p&gt;

&lt;p&gt;Two removals, two returns, and the interval got shorter. That second number is what made me stop&lt;br&gt;
and read.&lt;/p&gt;
&lt;h2&gt;
  
  
  The label is not a setting
&lt;/h2&gt;

&lt;p&gt;Here is the mechanism, and it is stated plainly in the same documentation page I had skipped:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If the Actor fails to complete successful runs for three consecutive days, the developer will be&lt;br&gt;
notified, and the Actor will be labeled under maintenance until it is fixed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the other direction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The best course of action is to fix the Actor and rebuild it. The automatic testing system will&lt;br&gt;
pick this up within 24 hours and mark it as healthy. In some cases, your Actor might break because&lt;br&gt;
of issues with the target website. In such a case, if your Actor passes the majority of test runs&lt;br&gt;
in the next 7 days, it will be marked as healthy automatically.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read those two together. The label is &lt;strong&gt;derived from your run history&lt;/strong&gt;, and the system recomputes&lt;br&gt;
it. The switch in Console writes to a field that an evaluator overwrites on its own schedule.&lt;/p&gt;

&lt;p&gt;For a normal broken Actor, the switch is honest and useful. You fix the bug, you rebuild, you clear&lt;br&gt;
the label, and the next evaluation agrees with you. Your manual action and the computed state&lt;br&gt;
converge.&lt;/p&gt;

&lt;p&gt;My case had no convergence. The failures were still in the window and would be there tomorrow too.&lt;br&gt;
Every removal I made was a claim the evidence contradicted, so the evaluator restored it.&lt;/p&gt;

&lt;p&gt;I also stopped for a second reason, which has nothing to do with mechanics. Repeatedly toggling a&lt;br&gt;
flag that an automated system keeps restoring is not a fix. It is an argument with a robot, in&lt;br&gt;
public, on my own store listing. I did not want to find out how that reads from the other side.&lt;/p&gt;
&lt;h2&gt;
  
  
  The path that actually worked
&lt;/h2&gt;

&lt;p&gt;The documentation tells you where to go:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If that's the case with your Actor, contact support and explain your specific use case that&lt;br&gt;
justifies why the Actor should be excluded from the automated tests.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is also a form. While my Actor was flagged, Console opened a skip-test request straight from a&lt;br&gt;
query parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://console.apify.com/actors/&amp;lt;actorId&amp;gt;/publication?showSkipTestForm=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat I can only report, not explain. Two days later, with the exemption granted, that same URL&lt;br&gt;
renders the ordinary publication page for me. So treat it as something that is there while you are&lt;br&gt;
flagged, not as a permanent entry point. If it does not open, the documented route is support, and&lt;br&gt;
that is the route the docs tell you to take anyway.&lt;/p&gt;

&lt;p&gt;I filled it in with three sentences: what the Actor does, why the test account cannot authorize the&lt;br&gt;
connector, and the sentence from the docs that describes exactly my situation. Quoting their own&lt;br&gt;
documentation back to them felt lazy. It was the right call — it turned a request for special&lt;br&gt;
treatment into a request to apply a rule that already existed.&lt;/p&gt;

&lt;p&gt;The reply came in about 31 hours:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;After reviewing your situation, we've approved your request. Your Actor will now not be checked by&lt;br&gt;
our tests.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I cleared the label a third time. This one held, because there is no longer an evaluator&lt;br&gt;
producing a verdict to overwrite it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The order matters and I had it backwards.&lt;/strong&gt; Get the exemption first. Clear the label second. Doing&lt;br&gt;
it the other way round produces exactly what I got: a label that returns on a schedule you do not&lt;br&gt;
control.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to check the flag without lying to yourself
&lt;/h2&gt;

&lt;p&gt;While I was doing this I needed a reliable way to answer "is it flagged right now?" I tried three&lt;br&gt;
and only one of them is trustworthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Console banner&lt;/strong&gt; is fine, but it is one Actor at a time and it needs a browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The public Store page is not reliable.&lt;/strong&gt; After my third removal I fetched the page and grepped for&lt;br&gt;
the phrase. The badge was still in the HTML, on a page that was already correct in Console. When I&lt;br&gt;
checked again later the same day, it was gone. So the page is not wrong — it lags, and on that day&lt;br&gt;
the lag was hours. If you grep the page right after a change, you will read your own stale render&lt;br&gt;
and conclude the change failed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The API is the source of truth.&lt;/strong&gt; The Actor object carries a top-level &lt;code&gt;notice&lt;/code&gt; field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/aiqlabs~dataset-to-github-issues"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.load(sys.stdin)['data']['notice'])"&lt;/span&gt;
&lt;span class="c"&gt;# NONE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fciiz8k0ahl1lfcgq564y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fciiz8k0ahl1lfcgq564y.png" alt="Selected fields from the live API response for the Actor, showing notice set to NONE alongside isPublic true and three categories" width="700" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The values I have observed are &lt;code&gt;UNDER_MAINTENANCE&lt;/code&gt; and &lt;code&gt;NONE&lt;/code&gt;. This endpoint answers for public&lt;br&gt;
Actors without a token, which means you can check anyone's — including, before you fork it, the one&lt;br&gt;
you are about to depend on.&lt;/p&gt;

&lt;p&gt;I did not find &lt;code&gt;notice&lt;/code&gt; documented as the maintenance flag. I found it by reading the object.&lt;/p&gt;
&lt;h2&gt;
  
  
  A check you can run on everything you publish
&lt;/h2&gt;

&lt;p&gt;After this, I stopped trusting my memory of which Actors were healthy. This prints the flag for a&lt;br&gt;
list of slugs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Print the maintenance flag for every Actor you name. No token needed for public Actors.&lt;/span&gt;
&lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"aiqlabs"&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;slug &lt;span class="k"&gt;in &lt;/span&gt;dataset-to-github-issues github-repository-audit pdf-table-extractor&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;notice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;~&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;slug&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; d=json.load(sys.stdin).get('data') or {}; print(d.get('notice','MISSING'))"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-32s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$notice&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran it across the Actors I care about after the approval. All &lt;code&gt;NONE&lt;/code&gt;, including the twenty-two&lt;br&gt;
that were never at risk. That last part matters: it confirmed the flag had not spread from a shared&lt;br&gt;
cause.&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap next door
&lt;/h2&gt;

&lt;p&gt;One warning that cost me three rounds of rework, because it sits on the same Console screen.&lt;/p&gt;

&lt;p&gt;The Actor status switch lives under Publication → Display information. &lt;strong&gt;Saving that section dropped&lt;br&gt;
one of my categories, three times in a row.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had set three categories through the API. After each save, &lt;code&gt;OPEN_SOURCE&lt;/code&gt; was gone, and I put it&lt;br&gt;
back through the API.&lt;/p&gt;

&lt;p&gt;Three for three is not an accident. But I want to be exact about the limit of what I know: when I&lt;br&gt;
opened that same form today to write this, all three categories were sitting in the field. So I can&lt;br&gt;
report the outcome I measured and not the cause.&lt;/p&gt;

&lt;p&gt;So if you go anywhere near this screen, re-apply your categories through the API afterwards, and&lt;br&gt;
read them back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/aiqlabs~dataset-to-github-issues"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.load(sys.stdin)['data']['categories'])"&lt;/span&gt;
&lt;span class="c"&gt;# ['DEVELOPER_TOOLS', 'AUTOMATION', 'OPEN_SOURCE']&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not confirm this in the UI you just used. Confirm it in the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs to ignore this
&lt;/h2&gt;

&lt;p&gt;The label is not cosmetic, and the timeline is published:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;After another 14 days of failing runs, you will receive another notification. Finally, if the runs&lt;br&gt;
continue to fail after yet another 14 days, the Actor will be deprecated.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three days of failures to get labeled. Roughly a month of them to get deprecated. For an Actor that&lt;br&gt;
structurally cannot pass, that clock starts on the day you publish and never stops. Nothing in your&lt;br&gt;
code will change it, which is precisely why manual removal feels like it works and is the most&lt;br&gt;
expensive thing you can do with the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I now apply
&lt;/h2&gt;

&lt;p&gt;Before I publish an Actor that needs an authorized connector, I ask one question: &lt;strong&gt;is there any&lt;br&gt;
input the test account can supply that produces a successful run?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer is yes, I make sure that path is what &lt;code&gt;prefill&lt;/code&gt; describes, and the daily test becomes&lt;br&gt;
a free health check I get for nothing.&lt;/p&gt;

&lt;p&gt;If the answer is no, I file the skip request in the same session as the publish, before the first&lt;br&gt;
notification arrives. Then I never touch the status switch, because by then there is nothing left&lt;br&gt;
for it to argue with.&lt;/p&gt;

&lt;p&gt;The Actor in this story is &lt;a href="https://apify.com/aiqlabs/dataset-to-github-issues" rel="noopener noreferrer"&gt;dataset-to-github-issues&lt;/a&gt;.&lt;br&gt;
Its source is public under ISC at&lt;br&gt;
&lt;a href="https://github.com/ai-q-labs/dataset-to-github-issues" rel="noopener noreferrer"&gt;github.com/ai-q-labs/dataset-to-github-issues&lt;/a&gt;,&lt;br&gt;
and the connector it uses speaks the &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;.&lt;br&gt;
The rest of what I publish is at &lt;a href="https://apify.com/aiqlabs" rel="noopener noreferrer"&gt;apify.com/aiqlabs&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>automation</category>
      <category>apify</category>
    </item>
    <item>
      <title>I gave my Actor a GitHub MCP connector. It could see 44 tools and use 4.</title>
      <dc:creator>Ai-Q Labs</dc:creator>
      <pubDate>Fri, 18 Sep 2026 00:08:07 +0000</pubDate>
      <link>https://dev.to/apify/i-gave-my-actor-a-github-mcp-connector-it-could-see-44-tools-and-use-4-31ih</link>
      <guid>https://dev.to/apify/i-gave-my-actor-a-github-mcp-connector-it-could-see-44-tools-and-use-4-31ih</guid>
      <description>&lt;h1&gt;
  
  
  I gave my Actor a GitHub MCP connector. It could see 44 tools and use 4.
&lt;/h1&gt;

&lt;p&gt;I publish 22 audit Actors on Apify. They all answer one question: is this public record still true?&lt;/p&gt;

&lt;p&gt;The registry serves a package with no warning on it. Its repository has been archived. My Actors&lt;br&gt;
find that gap and write it into a dataset.&lt;/p&gt;

&lt;p&gt;Then nothing happens. A dataset is a place findings go to be correct in private.&lt;/p&gt;

&lt;p&gt;So I built the missing half. It reads an audit dataset, decides what deserves attention, and opens&lt;br&gt;
a GitHub issue for each finding.&lt;/p&gt;

&lt;p&gt;It reaches GitHub through an&lt;br&gt;
&lt;a href="https://blog.apify.com/announcing-mcp-connectors/" rel="noopener noreferrer"&gt;Apify MCP connector&lt;/a&gt;. That is a Model Context&lt;br&gt;
Protocol (MCP) server, wired into the Actor's input by the platform. My Actor never touches my&lt;br&gt;
token.&lt;/p&gt;

&lt;p&gt;I expected the interesting part to be the writing. It was not. The interesting part was how little&lt;br&gt;
the platform let my Actor do, and how much the connector &lt;em&gt;appeared&lt;/em&gt; to offer.&lt;/p&gt;

&lt;p&gt;At authorization, Console listed &lt;strong&gt;44 tools&lt;/strong&gt;. My run could see &lt;strong&gt;four&lt;/strong&gt;. Both numbers are correct,&lt;br&gt;
and the gap between them is the whole design.&lt;/p&gt;

&lt;p&gt;Here is what I built, where in the run the connector fires, and the four things that surprised me.&lt;br&gt;
One of them broke an assumption the Actor was designed around.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built, and why it is a separate Actor
&lt;/h2&gt;

&lt;p&gt;The obvious move was to add issue-filing to&lt;br&gt;
&lt;a href="https://apify.com/aiqlabs/github-repository-audit" rel="noopener noreferrer"&gt;&lt;code&gt;github-repository-audit&lt;/code&gt;&lt;/a&gt; directly. I did not,&lt;br&gt;
for a boring reason: that Actor was under an unrelated measurement, and changing its build would have&lt;br&gt;
destroyed the data.&lt;/p&gt;

&lt;p&gt;The boring reason turned out to be the right architecture. Filing issues has nothing to do with&lt;br&gt;
auditing. It needs a dataset, a tracker, and a policy. It does not need to know what npm is.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;dataset-to-github-issues&lt;/code&gt; takes a dataset ID and a connector, and nothing about my audit is&lt;br&gt;
hard-coded into it.&lt;/p&gt;

&lt;p&gt;The input schema is the interesting file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"datasetId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Audit dataset"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"editor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"resourcePicker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dataset"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"resourcePermissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"READ"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"githubConnector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GitHub connector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcpConnector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.githubcopilot.com/mcp/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"issue_read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"issue_write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"list_issues"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search_issues"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two declarations sit side by side. One names what the run may read. The other names what it may call&lt;br&gt;
through the connector. The platform enforces both. My code enforces neither.&lt;/p&gt;

&lt;p&gt;That symmetry is easy to miss when you are writing it. It matters later.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting up the connector: the docs and Console disagree
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://docs.apify.com/platform/integrations/mcp-connectors" rel="noopener noreferrer"&gt;MCP connectors documentation&lt;/a&gt; says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Supported services include Notion, Slack, GitHub, Sentry, and Supabase.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;strong&gt;Add new MCP connector&lt;/strong&gt; dropdown in Console offers three presets. I scrolled to be sure. The&lt;br&gt;
list does not grow.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Preset&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sentry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://mcp.sentry.dev/mcp&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notion&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://mcp.notion.com/mcp&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linear&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://mcp.linear.app/sse&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Slack, GitHub, and Supabase are not presets. Linear is a preset and is not in that sentence.&lt;/p&gt;

&lt;p&gt;This is not a bug. The URL field accepts free text, so the presets are a convenience, not an&lt;br&gt;
allowlist. I typed the URL of&lt;br&gt;
&lt;a href="https://github.com/github/github-mcp-server" rel="noopener noreferrer"&gt;GitHub's own MCP server&lt;/a&gt; and waited.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://api.githubcopilot.com/mcp/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About four seconds later the field turned green and an auth panel appeared. Apify had gone and asked&lt;br&gt;
the server what it supports. It offered &lt;strong&gt;API key&lt;/strong&gt;, selected by default. It rendered &lt;strong&gt;OAuth&lt;/strong&gt;&lt;br&gt;
grayed out, with this text:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This server doesn't support dynamic client registration. Your own OAuth client is recommended.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I like that the platform probes rather than assumes. It also told me exactly which path to take on a&lt;br&gt;
free account: a personal access token used as a bearer credential.&lt;/p&gt;
&lt;h3&gt;
  
  
  The token I actually issued
&lt;/h3&gt;

&lt;p&gt;I used a &lt;a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens" rel="noopener noreferrer"&gt;fine-grained personal access token&lt;/a&gt;,&lt;br&gt;
scoped as tightly as GitHub allows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope element&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Repository access&lt;/td&gt;
&lt;td&gt;Only select repositories → one repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permissions&lt;/td&gt;
&lt;td&gt;Issues: read and write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permissions&lt;/td&gt;
&lt;td&gt;Metadata: read-only (added automatically, marked &lt;em&gt;Required&lt;/em&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expiry&lt;/td&gt;
&lt;td&gt;30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No contents write. No administration. No second repository. Remember this table; it is the point of&lt;br&gt;
the next section.&lt;/p&gt;
&lt;h3&gt;
  
  
  The trap: the URL reverted while I was not looking
&lt;/h3&gt;

&lt;p&gt;Between filling in the dialog and pasting the API key, the server URL changed under me. It went back&lt;br&gt;
to &lt;code&gt;https://mcp.sentry.dev/mcp&lt;/code&gt;, the first preset in the list. The field label also changed from&lt;br&gt;
"MCP server URL" to "MCP server".&lt;/p&gt;

&lt;p&gt;My API key field kept its contents. The URL showed a green validation check. Save was enabled.&lt;/p&gt;

&lt;p&gt;If I had saved that, a GitHub token would have been registered as the bearer credential for&lt;br&gt;
&lt;strong&gt;Sentry's&lt;/strong&gt; MCP server. Nothing in the dialog would have complained.&lt;/p&gt;

&lt;p&gt;Re-typing the GitHub URL and picking it from the suggestion fixed it, and the key survived the&lt;br&gt;
change.&lt;/p&gt;

&lt;p&gt;I have a rule now: read the server URL again immediately before you save. Typing it once is not&lt;br&gt;
enough. A connector dialog holds a live credential, and it deserves the same paranoia as a payment&lt;br&gt;
form.&lt;/p&gt;
&lt;h2&gt;
  
  
  44 tools discovered, 4 of them visible
&lt;/h2&gt;

&lt;p&gt;Saving the connector produced an ID and a list. Console showed the tools it had discovered at&lt;br&gt;
authorization time. All 44:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;add_comment_to_pending_review  add_issue_comment  add_reply_to_pull_request_comment
create_branch  create_or_update_file  create_pull_request  create_repository  delete_file
fork_repository  get_commit  get_file_contents  get_label  get_latest_release  get_me
get_release_by_tag  get_tag  get_team_members  get_teams  issue_read  issue_write
list_branches  list_commits  list_issue_fields  list_issue_types  list_issues
list_pull_requests  list_releases  list_repository_collaborators  list_tags
merge_pull_request  pull_request_read  pull_request_review_write  push_files
request_copilot_review  run_secret_scanning  search_code  search_commits  search_issues
search_pull_requests  search_repositories  search_users  sub_issue_write
update_pull_request  update_pull_request_branch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4mhig2gbe3yj0lk49v90.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4mhig2gbe3yj0lk49v90.jpg" alt="Apify Console listing the 44 tools discovered on the GitHub MCP connector at authorization time" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read that list against my token's permissions. &lt;code&gt;create_repository&lt;/code&gt; is there. &lt;code&gt;delete_file&lt;/code&gt; is there.&lt;br&gt;
&lt;code&gt;merge_pull_request&lt;/code&gt;, &lt;code&gt;push_files&lt;/code&gt;, &lt;code&gt;fork_repository&lt;/code&gt;, &lt;code&gt;run_secret_scanning&lt;/code&gt; — all there, all far&lt;br&gt;
outside a token that can only read and write issues in one repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The discovered tool list describes the server, not the token.&lt;/strong&gt; It is a menu, not a grant. Every&lt;br&gt;
one of those calls would die upstream, at GitHub itself.&lt;/p&gt;

&lt;p&gt;That is a fine outer boundary. It is also the worst possible place to learn about it. So I looked at&lt;br&gt;
what my run actually got. From the run log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO  Read 12 row(s) from dataset iBbLEMf3NUSJcU0cZ.
INFO  The proxy exposes 4 tool(s) to this run: issue_read, issue_write, list_issues, search_issues
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four. Exactly the four names in &lt;code&gt;mcpServers[0].tools.required&lt;/code&gt;, and nothing else. I changed nothing&lt;br&gt;
about the connector between those two observations. The&lt;br&gt;
&lt;a href="https://docs.apify.com/platform/actors/development/actor-definition/input-schema/specification/v1" rel="noopener noreferrer"&gt;input schema declaration&lt;/a&gt;&lt;br&gt;
is what narrowed it.&lt;/p&gt;

&lt;p&gt;The docs put it in one sentence, and now I have watched it happen:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The proxy enforces that an Actor can only call tools it explicitly declared in its input schema.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F005f7w5noerw499s8veh.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F005f7w5noerw499s8veh.jpg" alt="Apify run log showing the line: The proxy exposes 4 tool(s) to this run, naming issue_read, issue_write, list_issues and search_issues" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So there are three layers, and they do different jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The token.&lt;/strong&gt; What the upstream service will honor. Enforced by GitHub, discovered on failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The connector.&lt;/strong&gt; What server this credential belongs to. Chosen once, in Console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The input schema.&lt;/strong&gt; What &lt;em&gt;this Actor&lt;/em&gt; may call. Enforced by the proxy, before the request leaves
Apify.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Layer 3 is the one that ties a tool to a specific Actor. It is also the only one a reader of my Actor&lt;br&gt;
can see. Anyone can open my input schema and learn the worst thing my code can do to their GitHub&lt;br&gt;
account.&lt;/p&gt;

&lt;p&gt;I value that as much as the enforcement itself. A limit nobody can inspect is a promise.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the connector fires, and why not at the end
&lt;/h2&gt;

&lt;p&gt;The easy design writes at the end. Audit, decide, file, done.&lt;/p&gt;

&lt;p&gt;I fire the connector in the middle, twice, and the first call is a read. The Actor lists the open&lt;br&gt;
issues in the tracker before it decides anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This is the whole reason the connector fires here and not at the end: the decision of&lt;/span&gt;
&lt;span class="c1"&gt;// whether to open an issue depends on what is already open.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readTool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;argsFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;readTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;methodValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;readTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/list/&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;perPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;issue&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// a title like "[dep-drift] npm:left-pad - deprecated..." gives back "npm:left-pad"&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;keyFromTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;openKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identity lives in the issue title. Every issue my Actor opens is tagged &lt;code&gt;[dep-drift]&lt;/code&gt;, and the&lt;br&gt;
finding key follows the tag. A later run recovers the key by parsing titles it wrote itself. No&lt;br&gt;
state file, no external store, nothing to fall out of sync.&lt;/p&gt;

&lt;p&gt;I proved it by running the same input twice, live, with the same settings.&lt;/p&gt;

&lt;p&gt;First run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO  0 finding(s) already have an open issue (read 0 title(s) over 1 page(s)).
INFO  Opened 3 issue(s).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second run, identical input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO  3 finding(s) already have an open issue (read 3 title(s) over 1 page(s)).
INFO  Opened 3 issue(s).
INFO  Done. skipped: 7, filed: 3, deferred: 2.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Actor recognized its own three issues from the first run and skipped them. It filed the next&lt;br&gt;
three. Two more hit the per-run ceiling, and it reported them as &lt;code&gt;deferred&lt;/code&gt; instead of dropping&lt;br&gt;
them.&lt;/p&gt;

&lt;p&gt;Every row says why, in the dataset, whether or not it became an issue:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwer9c65nvdywncn0lfdz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwer9c65nvdywncn0lfdz.jpg" alt="Apify dataset table listing each finding with its decision and reason: three skipped because an open issue already covers them, three filed with issue numbers 4, 5 and 6, two deferred over the ceiling" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is what a connector buys that a separate script does not.&lt;/strong&gt; A write-only integration would&lt;br&gt;
have re-filed the same three issues. Then it would do it again every scheduled run, and the tracker&lt;br&gt;
becomes noise inside a week. Reading and writing in the same run, through the same authorization,&lt;br&gt;
is what makes re-running safe.&lt;/p&gt;

&lt;p&gt;The Actor also refuses to write blind. If the read fails, it stops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;readError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`Reading the existing issues failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;readError&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Stopping before any write, because `&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;without that list this run cannot tell a new finding from one it filed last time.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Duplicates in someone's tracker cost more than a failed run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assumption that broke
&lt;/h2&gt;

&lt;p&gt;My audit answers in three states. It found a defect. It found nothing. Or it could not check —&lt;br&gt;
usually because GitHub's hourly allowance ran out mid-run.&lt;/p&gt;

&lt;p&gt;The third state is the one I care about. Filing it turns "I don't know" into an alarm. Dropping it&lt;br&gt;
silently turns an unanswered question into a clean bill of health.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;decide()&lt;/code&gt; gives it its own outcome:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;minRiskLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findingKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;real&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;realCodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// codes that mean "we found something"&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unverified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;unverifiedCodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// codes that mean "the check did not run"&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;real&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unverified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;withheld&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`not verified (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;unverified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skipped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nothing to report&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;openKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skipped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;an open issue already covers this&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;riskLevel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;real&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To exercise that path honestly, I needed an audit that really ran out of allowance. So I pointed the&lt;br&gt;
audit Actor at &lt;code&gt;gatsby&lt;/code&gt;'s &lt;code&gt;package.json&lt;/code&gt; — &lt;strong&gt;166 dependencies&lt;/strong&gt;. It duly ran out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"github"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"requestsMade"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;56&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distinctRepositories"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;131&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"repositoriesNotChecked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rateLimited"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"findings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"not_checked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;76&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"repository_not_on_github"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;76&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"repo_moved"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"deprecated_on_registry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"repo_archived"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"silent_abandonment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seventy-six unverified rows. I expected a dry run to report dozens of withheld findings. It reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO  Done. skipped: 161, would-file: 5.
0 finding(s) were withheld because the audit could not verify them
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero.&lt;/p&gt;

&lt;p&gt;I read the rows instead of guessing at the cause. &lt;strong&gt;Every single &lt;code&gt;not_checked&lt;/code&gt; row also carried&lt;br&gt;
&lt;code&gt;repository_not_on_github&lt;/code&gt;.&lt;/strong&gt; Not one row in 166 had an unverified code and nothing else.&lt;/p&gt;

&lt;p&gt;That kills the assumption. I had built the decision around "a finding is either established or&lt;br&gt;
unestablished". Real data says a row is usually &lt;strong&gt;both&lt;/strong&gt;: something the audit established, plus a&lt;br&gt;
caveat about a check that did not run. The whole-row case turns out to be the rare one.&lt;/p&gt;

&lt;p&gt;The behavior that actually mattered was the other one. The caveat has to travel &lt;em&gt;with&lt;/em&gt; the finding,&lt;br&gt;
into the issue body. Here is issue #11, read back from the public GitHub API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**npm:string-similarity**&lt;/span&gt; — risk: &lt;span class="sb"&gt;`high`&lt;/span&gt;

&lt;span class="gu"&gt;### What the audit found&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`deprecated_on_registry`&lt;/span&gt; (high) — The registry marks this deprecated: "Package no longer supported..."
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`repository_not_on_github`&lt;/span&gt; (low) — The registry links to git://github.com/aceakash/string-similarity.git,
  which is not a GitHub repository.

&lt;span class="gu"&gt;### What the audit could not check&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`not_checked`&lt;/span&gt; — aceakash/string-similarity was not checked: GitHub's hourly allowance ran out.

These are open questions, not clean results. Re-run the audit to close them.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reader cannot mistake that last section for a clean result. The design goal held. The code path I&lt;br&gt;
built to demonstrate it never fired.&lt;/p&gt;

&lt;p&gt;I am leaving &lt;code&gt;withheld&lt;/code&gt; in, with its unit tests, and saying plainly that no live audit has produced&lt;br&gt;
one yet. It is designed behavior. I have never watched it happen.&lt;/p&gt;
&lt;h2&gt;
  
  
  The reply that looked complete and was not
&lt;/h2&gt;

&lt;p&gt;Run 4 opened three issues and then crashed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO  Opened 3 issue(s).
ApifyApiError: Schema validation failed   clientMethod: DatasetClient.pushItems
  instancePath: '/issueNumber'  message: 'must be integer'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issues were real. I checked them from outside the run with an unauthenticated &lt;code&gt;curl&lt;/code&gt;. The&lt;br&gt;
&lt;em&gt;report&lt;/em&gt; is what failed, because &lt;code&gt;issueNumber&lt;/code&gt; came back &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had stored the first raw reply in the key-value store on purpose, so I could look instead of&lt;br&gt;
guess. This is &lt;code&gt;issue_write&lt;/code&gt;'s answer, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"5078084249"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://github.com/ai-q-labs/github-repository-audit/issues/4"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is &lt;strong&gt;no &lt;code&gt;number&lt;/code&gt; field&lt;/strong&gt;. &lt;code&gt;id&lt;/code&gt; is a string, and it holds GitHub's internal identifier. The&lt;br&gt;
issue number — 4 — exists only inside the URL.&lt;/p&gt;

&lt;p&gt;Reaching for &lt;code&gt;id&lt;/code&gt; would have recorded issue "5078084249". Nothing would have errored. My dataset&lt;br&gt;
would have looked complete and been wrong in a way no type check catches.&lt;/p&gt;

&lt;p&gt;The fix reads the number where it actually lives, and refuses to invent one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findIssueRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;issue_number&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// note: a string id fails this on purpose&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;html_url&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;htmlUrl&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;github&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;com&lt;/span&gt;&lt;span class="se"&gt;\/[\w&lt;/span&gt;&lt;span class="sr"&gt;.-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\/[\w&lt;/span&gt;&lt;span class="sr"&gt;.-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;issues&lt;/span&gt;&lt;span class="se"&gt;\/(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Number.isInteger&lt;/code&gt; is doing real work there. It rejects the string &lt;code&gt;id&lt;/code&gt; that would otherwise sail&lt;br&gt;
through.&lt;/p&gt;

&lt;p&gt;If you build against a connector, capture the first reply from every tool you call. A tool result is&lt;br&gt;
whatever the upstream server decided to send. Assuming a field name and discovering the truth in&lt;br&gt;
production is exactly the failure this prevents.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two more failures worth designing for
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A run cannot open an arbitrary dataset.&lt;/strong&gt; My very first run died here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ACTOR: Running under "LIMITED_PERMISSIONS".
ERROR Could not read dataset iBbLEMf3NUSJcU0cZ: Insufficient permissions for the dataset.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same account, same owner, still refused. The fix is not a token — it is the &lt;code&gt;resourceType&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;resourcePermissions&lt;/code&gt; declaration from the schema at the top of this article. The schema names the&lt;br&gt;
thing being read and the thing being written through. The platform grants exactly that, and no more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A run that changed the outside world must not fail on its own bookkeeping.&lt;/strong&gt; That crash after&lt;br&gt;
"Opened 3 issue(s)" marked the whole run FAILED. FAILED reads as "nothing happened". That is the&lt;br&gt;
opposite of the truth, and it sends the next run straight back at the same findings.&lt;/p&gt;

&lt;p&gt;I now guard each dataset write on its own. Anything that fails lands in the summary instead of&lt;br&gt;
killing the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;decisions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;reportProblems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order the work so the irreversible part happens last, and make everything after it non-fatal.&lt;/p&gt;

&lt;p&gt;Seven runs got me here, and the first two are the ones worth reading:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzx5sb1oyfrnw778c5sx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzx5sb1oyfrnw778c5sx.jpg" alt="Apify run list for the Actor showing seven runs, including one that failed on dataset permissions and one that failed on a mistyped connector id" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug my new Actor found in my old one
&lt;/h2&gt;

&lt;p&gt;Look again at issue #11. The registry link is &lt;code&gt;git://github.com/aceakash/string-similarity.git&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That &lt;strong&gt;is&lt;/strong&gt; on GitHub. The same row resolves &lt;code&gt;aceakash/string-similarity&lt;/code&gt; and queues it for a GitHub&lt;br&gt;
lookup. One row, two statements, contradicting each other.&lt;/p&gt;

&lt;p&gt;My first explanation was that the URL parser rejects the &lt;code&gt;git://&lt;/code&gt; scheme. I ran the parser to check,&lt;br&gt;
and it does not. It accepts &lt;code&gt;git://&lt;/code&gt;, &lt;code&gt;git+ssh://&lt;/code&gt;, &lt;code&gt;git@host:path&lt;/code&gt; and &lt;code&gt;github:owner/name&lt;/code&gt; alike.&lt;/p&gt;

&lt;p&gt;The real cause is one line further on. The finding fires when the GitHub record is missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;registryFound&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repoUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no_repository_link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The registry record does not link to any source repository, so nothing can be verified against it.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;registryFound&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repoUrl&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;repository_not_on_github&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`The registry links to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repoUrl&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;, which is not a GitHub repository.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The record is also missing when the lookup never ran. GitHub allows an unauthenticated caller 60&lt;br&gt;
requests an hour. Past that, my Actor stops asking and stores &lt;code&gt;null&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rateLimited&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;skipped&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;!repo&lt;/code&gt; means two different things, and the code reads only one of them. Every row cut off by the&lt;br&gt;
rate limit is reported as a repository that is not on GitHub. That is why all 76 unverified rows were&lt;br&gt;
paired. The pairing tracks the rate limit. The URL scheme has nothing to do with it.&lt;/p&gt;

&lt;p&gt;It is the same mistake as the one in my first article, made a fifth time. A missing answer is not a&lt;br&gt;
negative answer.&lt;/p&gt;

&lt;p&gt;I have not fixed it. &lt;code&gt;github-repository-audit&lt;/code&gt; is inside an unrelated observation window, and&lt;br&gt;
changing its build would destroy that measurement. It is recorded, and it goes in next.&lt;/p&gt;

&lt;p&gt;I did not expect the reader of an Actor's output to become the best test of that Actor. It is&lt;br&gt;
obvious in hindsight. A dataset you only look at is a dataset nobody checks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fko87pva87czrybn10uze.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fko87pva87czrybn10uze.jpg" alt="GitHub issues list showing eleven dep-drift issues opened by the Actor across two audit datasets" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost, and what I would do differently
&lt;/h2&gt;

&lt;p&gt;Eleven issues, none duplicated, across four live runs and two audit datasets. All of it on the&lt;br&gt;
&lt;strong&gt;free plan&lt;/strong&gt;. When I stopped, my account usage read $0.61 against the $5 monthly credit. MCP&lt;br&gt;
connectors are not a paid feature, which surprised me enough to check twice.&lt;/p&gt;

&lt;p&gt;Three things I would tell myself before starting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Declare the smallest tool set you can name.&lt;/strong&gt; Not for safety theater — because the declaration
is public. Anyone can read what my Actor is allowed to do without reading my code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fire the connector where the decision is, not where the output is.&lt;/strong&gt; Read before you write, and
stop if the read fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the first reply from every tool.&lt;/strong&gt; The one that cost me a run was shaped nothing like I
assumed. It failed quietly, in the one direction a type check cannot catch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And one I got wrong: I designed a decision path around a data shape I had never seen. A branch, a&lt;br&gt;
reason string, and unit tests, for a row that has not appeared once in 178 audited packages. Next&lt;br&gt;
time I look at the data before I write the branch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run it yourself
&lt;/h3&gt;

&lt;p&gt;The full source of the Actor in this article is at&lt;br&gt;
&lt;a href="https://github.com/ai-q-labs/dataset-to-github-issues" rel="noopener noreferrer"&gt;&lt;code&gt;ai-q-labs/dataset-to-github-issues&lt;/code&gt;&lt;/a&gt; —&lt;br&gt;
the input schema, the decision function, the connector client, and the unit tests, including the one&lt;br&gt;
covering the case that has never fired.&lt;/p&gt;

&lt;p&gt;You will need three things to run it: an audit dataset with &lt;code&gt;riskLevel&lt;/code&gt; and &lt;code&gt;issueCodes&lt;/code&gt; on each&lt;br&gt;
row, an MCP connector authorized against GitHub, and a repository you are willing to file issues in.&lt;br&gt;
A free Apify account covers the rest.&lt;/p&gt;

&lt;p&gt;Where the findings came from:&lt;br&gt;
&lt;a href="https://apify.com/aiqlabs/github-repository-audit" rel="noopener noreferrer"&gt;&lt;code&gt;github-repository-audit&lt;/code&gt;&lt;/a&gt; is public and free.&lt;br&gt;
Everything it produced through the connector is open in the&lt;br&gt;
&lt;a href="https://github.com/ai-q-labs/github-repository-audit/issues" rel="noopener noreferrer"&gt;tracker repository&lt;/a&gt;, tagged&lt;br&gt;
&lt;code&gt;[dep-drift]&lt;/code&gt;, including the one that exposed my own parser bug.&lt;/p&gt;

&lt;p&gt;Full-size versions of every screenshot in this article sit in the source repository above, under&lt;br&gt;
&lt;code&gt;docs/screenshots&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The shortest version of the whole thing: your connector shows you a menu. Your Actor declares what it&lt;br&gt;
eats. The proxy is what makes the difference real.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I connected my audit Actor to Claude, and it audited three packages nobody asked for</title>
      <dc:creator>Ai-Q Labs</dc:creator>
      <pubDate>Fri, 18 Sep 2026 00:07:21 +0000</pubDate>
      <link>https://dev.to/apify/i-connected-my-audit-actor-to-claude-and-it-audited-three-packages-nobody-asked-for-3m1d</link>
      <guid>https://dev.to/apify/i-connected-my-audit-actor-to-claude-and-it-audited-three-packages-nobody-asked-for-3m1d</guid>
      <description>&lt;h1&gt;
  
  
  I connected my audit Actor to Claude, and it audited three packages nobody asked for
&lt;/h1&gt;

&lt;p&gt;I maintain 22 audit Actors on Apify. They all do a version of the same thing. They take a public&lt;br&gt;
record, and they check whether what it still claims is true.&lt;/p&gt;

&lt;p&gt;Last week I connected one of them to Claude through the Apify Model Context Protocol (MCP) server. I&lt;br&gt;
expected the interesting part to be the agent. It was not. The interesting part was my own input&lt;br&gt;
schema. It had been lying to every non-human caller since the day I published it.&lt;/p&gt;

&lt;p&gt;Then I made a second assumption. I decided I knew how an agent would read the result. I gave the&lt;br&gt;
question to four of them to prove it, and all four proved the opposite.&lt;/p&gt;

&lt;p&gt;This is what I found, what the agents did with it, and what I changed.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the Actor does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/aiqlabs/github-repository-audit" rel="noopener noreferrer"&gt;&lt;code&gt;github-repository-audit&lt;/code&gt;&lt;/a&gt; takes package names or&lt;br&gt;
repository names. For each one it asks two sources the same question and compares the answers.&lt;/p&gt;

&lt;p&gt;The registry says a package points at a repository. The repository says whether it is archived, moved,&lt;br&gt;
or relicensed. Those two records disagree more often than you would think.&lt;/p&gt;

&lt;p&gt;The finding I built it for is the quiet one. Here is real output from a run:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;npm&lt;/code&gt; still serves &lt;code&gt;cross-env@10.1.0&lt;/code&gt; with no deprecation notice, while its repository&lt;br&gt;
&lt;code&gt;kentcdodds/cross-env&lt;/code&gt; has been archived. Installing it looks completely normal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nothing in your terminal tells you. &lt;code&gt;npm install&lt;/code&gt; prints no warning. The archive banner sits on&lt;br&gt;
&lt;a href="https://github.com/kentcdodds/cross-env" rel="noopener noreferrer"&gt;the repository&lt;/a&gt; where nobody installing the package will&lt;br&gt;
look. The maintainer said goodbye in the only place they could. The registry never passed the message&lt;br&gt;
on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwwssoj47wxvwtm1azii8.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwwssoj47wxvwtm1azii8.jpg" alt="The Actor's page in Apify Console, showing its pay-per-event price and source files." width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Connecting it through the Apify MCP server
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://docs.apify.com/platform/integrations/mcp" rel="noopener noreferrer"&gt;Apify MCP server&lt;/a&gt; turns an Actor into a tool an&lt;br&gt;
agent can call. The connection itself took one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http apify &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://mcp.apify.com?tools=aiqlabs/github-repository-audit"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;?tools=&lt;/code&gt; parameter matters more than it looks. Without it, the server exposes its whole surface.&lt;br&gt;
With it, the agent sees exactly one tool. I wanted a clean experiment, so I scoped it to one Actor.&lt;/p&gt;

&lt;p&gt;Authentication runs over OAuth with dynamic client registration and PKCE. I never handled a token.&lt;br&gt;
The consent screen is worth reading rather than clicking through. Apify prints the honest version:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This application was registered dynamically, and wasn't verified by Apify. Make sure you trust it.&lt;br&gt;
It's allowed to redirect you to following URL(s): &lt;code&gt;http://localhost/callback&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the right warning to show. Dynamic registration means nobody vetted the client.&lt;/p&gt;
&lt;h3&gt;
  
  
  The detour
&lt;/h3&gt;

&lt;p&gt;My first three attempts failed, and the reason had nothing to do with Apify.&lt;/p&gt;

&lt;p&gt;I was driving the CLI from a non-interactive shell. &lt;code&gt;claude mcp login&lt;/code&gt; prints the authorization URL,&lt;br&gt;
starts waiting, and then gives up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Waiting for authorization… (^C to cancel)
Couldn't complete authentication: stdin isn't a terminal.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The login flow wants a real terminal even when the callback would arrive over the network. I tried&lt;br&gt;
&lt;code&gt;winpty&lt;/code&gt; next. &lt;code&gt;winpty&lt;/code&gt; refused for the same reason one level down. It needs a console of its own.&lt;/p&gt;

&lt;p&gt;What worked was a batch file, launched in a fresh console window, with stdout sent to a file. The&lt;br&gt;
console satisfies the terminal check. The redirect lets me read the authorization URL. Then I opened&lt;br&gt;
that URL, approved it, and the callback landed on &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Write that down if you automate this. The blocker is the terminal, not the network.&lt;/p&gt;
&lt;h2&gt;
  
  
  The first run returned four rows for a one-repository question
&lt;/h2&gt;

&lt;p&gt;Before handing the tool to an agent, I wanted to see the smallest possible call. So I sent one field&lt;br&gt;
and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"repos"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"facebook/create-react-app"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 39 bytes. The run recorded an &lt;code&gt;inputBodyLen&lt;/code&gt; of 328.&lt;/p&gt;

&lt;p&gt;Something had grown my input by a factor of eight. This is what the platform actually stored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"repos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"facebook/create-react-app"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"packages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"npm:request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:babel-eslint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:left-pad"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"defaultRegistry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"manifestType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"manifestGroups"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dependencies"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"staleAfterDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;365&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeContributors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeReleases"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"onlyIssues"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxTargets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxConcurrency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requestTimeoutSecs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi919njh296th9hwtxtlx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi919njh296th9hwtxtlx.jpg" alt="The INPUT record Apify saved for the run, shown as JSON in the Console. It holds the single repository I sent plus a packages array of three npm packages I never sent." width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three packages I had not mentioned were now part of the run. The results came back like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;input&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;source&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;riskLevel&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;issueCodes&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;facebook/create-react-app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repos&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;repo_moved&lt;/code&gt;, &lt;code&gt;stale_no_push&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm:request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;packages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;high&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deprecated_on_registry&lt;/code&gt;, &lt;code&gt;stale_no_push&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm:babel-eslint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;packages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;critical&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deprecated_on_registry&lt;/code&gt;, &lt;code&gt;repo_archived&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm:left-pad&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;packages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;critical&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deprecated_on_registry&lt;/code&gt;, &lt;code&gt;repo_moved&lt;/code&gt;, &lt;code&gt;repo_archived&lt;/code&gt;, &lt;code&gt;license_mismatch&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2w627omoyaypka6jtqi.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2w627omoyaypka6jtqi.jpg" alt="The run's output table. Row one, the repository I asked about, shows null for package name and medium risk. Rows two to four are npm packages I did not ask about, shown as high and critical." width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I asked about one repository. I got four rows. Every high and critical row belongs to something the&lt;br&gt;
caller never mentioned.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/request/request" rel="noopener noreferrer"&gt;&lt;code&gt;request&lt;/code&gt;&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://github.com/left-pad/left-pad" rel="noopener noreferrer"&gt;&lt;code&gt;left-pad&lt;/code&gt;&lt;/a&gt; are not neutral filler either. They are two of the&lt;br&gt;
most famously abandoned packages in the npm registry. They produce findings by design.&lt;/p&gt;
&lt;h2&gt;
  
  
  prefill and default are not the same word
&lt;/h2&gt;

&lt;p&gt;The cause is one line in my own input schema, written months earlier.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"repos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"prefill"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"facebook/create-react-app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"babel/babel-eslint"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"packages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"npm:request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:babel-eslint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:left-pad"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had used the two keys interchangeably. I thought both were examples for the form. The&lt;br&gt;
&lt;a href="https://docs.apify.com/platform/actors/development/actor-definition/input-schema/specification/v1" rel="noopener noreferrer"&gt;input schema specification&lt;/a&gt;&lt;br&gt;
is unambiguous, and I had simply never read this paragraph carefully:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Default&lt;/strong&gt; — If the user omits the value when starting the Actor via any means (API, CLI,&lt;br&gt;
scheduler, or user interface), the platform automatically passes the Actor this default value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefill&lt;/strong&gt; — this field is only used in the user interface but does not affect the Actor&lt;br&gt;
functionality and API.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;prefill&lt;/code&gt; is a suggestion. &lt;code&gt;default&lt;/code&gt; is a promise the platform keeps on your behalf.&lt;/p&gt;

&lt;p&gt;There is a second edge to this, and I only saw it once the Actor was a tool. The MCP server turns&lt;br&gt;
the input schema into a JSON Schema and hands it to the model. Here is what arrives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"packages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"npm:request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:babel-eslint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:left-pad"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"repos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"prefill"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"facebook/create-react-app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"babel/babel-eslint"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;default&lt;/code&gt; is a JSON Schema keyword. It means "the value used when this is omitted", and a model&lt;br&gt;
reading the tool definition can act on it. &lt;code&gt;prefill&lt;/code&gt; is not a JSON Schema keyword at all. It passes&lt;br&gt;
through as an unrecognised key.&lt;/p&gt;

&lt;p&gt;So the field nobody asked about announces itself in the language of the specification. The field that&lt;br&gt;
answers the question carries a word the model has no rule for. I had put the standard keyword on the&lt;br&gt;
wrong field.&lt;/p&gt;

&lt;p&gt;My Actor's own code is innocent here. It destructures with &lt;code&gt;packages = []&lt;/code&gt;. The injection happens&lt;br&gt;
above it, before &lt;code&gt;Actor.getInput()&lt;/code&gt; ever returns.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I assumed would happen next
&lt;/h2&gt;

&lt;p&gt;Open the Actor in Apify Console and the bug is visible immediately. The Packages box has three values&lt;br&gt;
sitting in it. You delete them, or you leave them, and either way you decided.&lt;/p&gt;

&lt;p&gt;The form is doing something important. It shows you the whole input, including the parts you did not&lt;br&gt;
supply.&lt;/p&gt;

&lt;p&gt;An agent never sees a form. It sends the fields it decided to send. It receives rows.&lt;/p&gt;

&lt;p&gt;My rows do carry a &lt;code&gt;source&lt;/code&gt; field, so the information needed to separate them exists. An agent that&lt;br&gt;
reads it can tell the four rows apart. I want to be exact about that. It is the part I got right by&lt;br&gt;
accident, because I did not add &lt;code&gt;source&lt;/code&gt; for this reason.&lt;/p&gt;

&lt;p&gt;But an agent does not read fifty-four fields per row back to a user. It reads the summary. So I&lt;br&gt;
opened the two records my Actor writes for exactly that purpose.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SUMMARY&lt;/code&gt; counts the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"byRiskLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"critical"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"licenseComparison"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"comparable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mismatchRate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.333&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"notComparable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two critical and one high, for a question about one repository. No count in that block says which&lt;br&gt;
findings belong to the caller's target. The mismatch rate is worse. It is a fraction whose&lt;br&gt;
denominator the caller never chose.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ACTION_LIST&lt;/code&gt; is the record I named for what to do next. Its entries carry &lt;code&gt;target&lt;/code&gt;, &lt;code&gt;registry&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;repo&lt;/code&gt;, &lt;code&gt;riskLevel&lt;/code&gt; and &lt;code&gt;issues&lt;/code&gt;. It has no &lt;code&gt;source&lt;/code&gt; field at all. It is sorted by severity:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;&lt;code&gt;target&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;riskLevel&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;did the caller ask for it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;babel-eslint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;critical&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;left-pad&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;critical&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;high&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;facebook/create-react-app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one entry that answers the question sits at the bottom. Above it are three the caller never&lt;br&gt;
mentioned. The record offers no field that would tell them apart.&lt;/p&gt;

&lt;p&gt;That is the shape of the trap. Attribution survives in the raw rows, which an agent skims. It&lt;br&gt;
disappears from both records built to be read instead.&lt;/p&gt;

&lt;p&gt;So I had a prediction, and it was a tidy one. Ask an agent whether create-react-app is safe to depend&lt;br&gt;
on. It will read &lt;code&gt;critical: 2&lt;/code&gt; and pass that on.&lt;/p&gt;
&lt;h2&gt;
  
  
  I was wrong four times in a row
&lt;/h2&gt;

&lt;p&gt;I had already read the schema, so my own answer proved nothing. I gave the question to four fresh&lt;br&gt;
agents instead. None had seen any of this. The Actor was still unfixed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is facebook/create-react-app safe to depend on?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;All four got it right.&lt;/strong&gt; Every one of them reported medium risk for create-react-app, which is the&lt;br&gt;
correct answer. Not one passed on the critical count.&lt;/p&gt;

&lt;p&gt;Three of the four went further and told the user my tool was broken.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One thing worth flagging about the tool: my first run asked about a single repository but produced&lt;br&gt;
4 rows. The extra 3 are the Actor's built-in defaults for the &lt;code&gt;packages&lt;/code&gt; field […] Those three&lt;br&gt;
carry the alarming findings — 2 critical, 1 high […] &lt;strong&gt;Reading that summary at face value would&lt;br&gt;
have produced a badly wrong answer.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Another said the same thing in different words, then added a line I did not want to read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;when you pass only &lt;code&gt;repos&lt;/code&gt; to this Actor, you have to set &lt;code&gt;packages&lt;/code&gt; to empty explicitly or&lt;br&gt;
unrelated rows get mixed in.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a workaround for my Actor, written by an agent, addressed to my user.&lt;/p&gt;
&lt;h3&gt;
  
  
  The trap still cost something
&lt;/h3&gt;

&lt;p&gt;Nobody was misled. I want to be careful not to soften that. But four correct answers is not the same&lt;br&gt;
as no harm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two of the four ran the audit twice.&lt;/strong&gt; Double the compute units, double the GitHub requests against&lt;br&gt;
a 60-per-hour unauthenticated allowance, double the wait. The caller paid to undo my mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One told the user the tool was defective.&lt;/strong&gt; That paragraph is now part of what my Actor looks like&lt;br&gt;
to somebody deciding whether to use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One said nothing at all.&lt;/strong&gt; The fourth agent gave a clean, correct answer and never mentioned the&lt;br&gt;
three rows it had discarded. The caller was billed for four rows and got the use of one. Nothing in&lt;br&gt;
the exchange told them.&lt;/p&gt;

&lt;p&gt;And the wrong answer was sitting there the whole time. &lt;code&gt;byRiskLevel&lt;/code&gt; still reads &lt;code&gt;critical: 2&lt;/code&gt; with&lt;br&gt;
nothing beside it to say whose. Four agents declined to take it. That is not the same as it not being&lt;br&gt;
there.&lt;/p&gt;
&lt;h3&gt;
  
  
  One of them found a second bug I had missed
&lt;/h3&gt;

&lt;p&gt;The agent that ran the audit twice explained why:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I ran the audit a second time because the repo-only run left &lt;code&gt;registryDeprecated&lt;/code&gt;, &lt;code&gt;licenseMatch&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;silentAbandonment&lt;/code&gt; all &lt;code&gt;null&lt;/code&gt; — &lt;strong&gt;it never consulted npm, so it couldn't have answered "safe&lt;br&gt;
to depend on" as asked.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is right. A repository has no registry side, so three checks cannot run on it. One of them is&lt;br&gt;
&lt;code&gt;silent_abandonment&lt;/code&gt;, the finding this Actor exists for. A caller who names a repository never gets&lt;br&gt;
it, and nothing said so. The fields just came back &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had written the rule against this in my own README. Return "broken", "fine" and "could not check"&lt;br&gt;
as three different values. Then I returned the third as a bare &lt;code&gt;null&lt;/code&gt; and let it read like the second.&lt;/p&gt;

&lt;p&gt;Two of the four agents worked around it the same way, without being asked. That is a design gap, not&lt;br&gt;
a coincidence.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;



&lt;p&gt;Five changes, in order of how much they mattered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I removed &lt;code&gt;default&lt;/code&gt; from every field that names a target.&lt;/strong&gt; Sample values moved to &lt;code&gt;prefill&lt;/code&gt;, which&lt;br&gt;
keeps the Console form useful and never reaches the API. A field that decides &lt;em&gt;what to audit&lt;/em&gt; must&lt;br&gt;
come from the caller. A field that decides &lt;em&gt;how to audit&lt;/em&gt; can have a default, and &lt;code&gt;staleAfterDays&lt;/code&gt;&lt;br&gt;
still does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I made the empty call fail loudly.&lt;/strong&gt; With no targets the Actor now stops with a message that names&lt;br&gt;
the three ways in. It used to be impossible to reach that path, because the default guaranteed there&lt;br&gt;
was always something to audit. That was the bug hiding the bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I moved the relevance rule into the description.&lt;/strong&gt; The &lt;code&gt;source&lt;/code&gt; field is now documented where an&lt;br&gt;
agent reads it, not only in the README.&lt;/p&gt;

&lt;p&gt;That last one had a second layer I only found while writing this. &lt;code&gt;source&lt;/code&gt; did not appear anywhere in&lt;br&gt;
my &lt;code&gt;dataset_schema.json&lt;/code&gt; either. So the default Console table did not show it, and neither did the&lt;br&gt;
"Problems only" view.&lt;/p&gt;

&lt;p&gt;Opening that table made it worse. My view leads with &lt;code&gt;packageName&lt;/code&gt;, and the row I asked for came from&lt;br&gt;
&lt;code&gt;repos&lt;/code&gt;, so it has no package name. The Console printed it as &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Package Name&lt;/th&gt;
&lt;th&gt;Registry&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Resolved Repo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;null&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;react/create-react-app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;request&lt;/td&gt;
&lt;td&gt;npm&lt;/td&gt;
&lt;td&gt;high&lt;/td&gt;
&lt;td&gt;request/request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;babel-eslint&lt;/td&gt;
&lt;td&gt;npm&lt;/td&gt;
&lt;td&gt;critical&lt;/td&gt;
&lt;td&gt;babel/babel-eslint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;left-pad&lt;/td&gt;
&lt;td&gt;npm&lt;/td&gt;
&lt;td&gt;critical&lt;/td&gt;
&lt;td&gt;left-pad/left-pad&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one row that answers the question looks like the broken one. The three rows nobody asked for look&lt;br&gt;
authoritative. I had built a view that ranked my own output by how little the reader wanted it.&lt;/p&gt;

&lt;p&gt;Both views now lead with the two columns that answer "did I ask for this row?": &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;source&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I made the summaries carry attribution too.&lt;/strong&gt; This is the change I would have missed if I had&lt;br&gt;
stopped at the schema. Removing the default stops this particular injection. It does not make the&lt;br&gt;
output attributable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ACTION_LIST&lt;/code&gt; now begins each entry with &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;source&lt;/code&gt;. &lt;code&gt;SUMMARY&lt;/code&gt; now carries a &lt;code&gt;bySource&lt;/code&gt;&lt;br&gt;
block beside the flat counts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bySource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"repos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"checked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"critical"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"packages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"checked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"critical"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"byRiskLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"critical"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flat line still says two critical. Now something next to it says whose.&lt;/p&gt;

&lt;p&gt;That block is eight lines in &lt;code&gt;src/audit.js&lt;/code&gt;, inside the function that builds &lt;code&gt;SUMMARY&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A single call can mix targets the caller typed with targets that arrived some&lt;/span&gt;
&lt;span class="c1"&gt;// other way - a manifest that expanded into forty dependencies, or a schema&lt;/span&gt;
&lt;span class="c1"&gt;// default. A flat count of "2 critical" cannot be acted on, because it does not&lt;/span&gt;
&lt;span class="c1"&gt;// say whose.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bySource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;bySource&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;high&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;medium&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;bySource&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;checked&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;riskLevel&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;bySource&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;bySource&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;riskLevel&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ACTION_LIST&lt;/code&gt; took two lines: &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;source&lt;/code&gt; moved to the front of each entry, above&lt;br&gt;
&lt;code&gt;target&lt;/code&gt;. That ordering is the whole change. A reader who cannot answer "did I ask for this?"&lt;br&gt;
cannot act on the row.&lt;/p&gt;

&lt;p&gt;This matters beyond the bug that started it. My Actor takes repositories, packages and a manifest in&lt;br&gt;
one call. A manifest URL can expand into forty dependencies from a single field. Any of those&lt;br&gt;
mixes produces a count the caller cannot take apart. The default was one way in. It was not the only&lt;br&gt;
one.&lt;/p&gt;

&lt;p&gt;Going through the rest of the output found one more. &lt;code&gt;LICENSE_REPORT&lt;/code&gt; ends with a list of licences&lt;br&gt;
that could not be resolved. That list is an instruction to open files by hand. It held bare names. So&lt;br&gt;
I gave those entries &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;source&lt;/code&gt; as well. Sending someone to read a licence for a dependency&lt;br&gt;
they never named wastes the same afternoon a false finding does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I made "could not check" say so out loud.&lt;/strong&gt; This is the one an agent found for me. A&lt;br&gt;
repository-only row now produces a note in &lt;code&gt;SUMMARY&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;1 target(s) were given as repositories, so no registry was consulted for them. registryDeprecated,&lt;br&gt;
licenseMatch and silentAbandonment are null on those rows because they could not be checked, not&lt;br&gt;
because they came back clean. Pass the package name to check them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same sentence went into the &lt;code&gt;repos&lt;/code&gt; field description, where an agent reads it before calling.&lt;br&gt;
&lt;code&gt;null&lt;/code&gt; was already the honest value. It was not a legible one.&lt;/p&gt;

&lt;p&gt;Four tests hold this. Three check the new fields on the three records. The fourth is a control. A run&lt;br&gt;
from a single source must report one group, and its numbers must equal the flat totals. So the&lt;br&gt;
breakdown cannot invent structure that is not there. The suite went from 48 to 52.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually changed, measured the same way
&lt;/h2&gt;

&lt;p&gt;I pushed build 0.1.8 and sent the same one-field call again.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;rows returned&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stored &lt;code&gt;INPUT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;328 bytes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;267 bytes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;byRiskLevel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;critical 2, high 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;critical 0, high 0&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bySource&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;present&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;first &lt;code&gt;ACTION_LIST&lt;/code&gt; entry&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;babel-eslint&lt;/code&gt;, critical&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the repository I asked about&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkxkf4pcc64ufw1s72kfv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkxkf4pcc64ufw1s72kfv.jpg" alt="The run's output table after the fix, on the Actor whose Store title now reads GitHub Scraper. One row, for the repository I asked about, with You asked for and Came from as the first two columns, and nothing I did not send." width="800" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then I ran the agent trial again, same question, same plain prompt, two fresh agents.&lt;/p&gt;

&lt;p&gt;Both answered correctly, as before. &lt;strong&gt;Neither mentioned a defect in the tool.&lt;/strong&gt; Before the change,&lt;br&gt;
three of four had. That is the entire measurable result, and it is a modest one. Nobody was ever&lt;br&gt;
given a wrong answer, so nothing about correctness improved.&lt;/p&gt;

&lt;p&gt;What improved is that my Actor stopped asking its callers to compensate for it.&lt;/p&gt;

&lt;p&gt;One thing did not change. Both agents still ran the audit twice, once by repository and once by&lt;br&gt;
package. That is now the right behaviour, and the output asks for it in writing. I should say that my&lt;br&gt;
new note may be causing the second call rather than merely permitting it. One agent made the same&lt;br&gt;
second call before the note existed, which argues against that. With four trials I cannot separate&lt;br&gt;
the two.&lt;/p&gt;
&lt;h2&gt;
  
  
  Then I checked the other twenty-one
&lt;/h2&gt;

&lt;p&gt;One bad field is a typo. I wanted to know whether it was a habit. So I read every input schema I&lt;br&gt;
have published and sorted the defaults into two piles.&lt;/p&gt;

&lt;p&gt;A default is safe when it decides &lt;strong&gt;how&lt;/strong&gt; the work is done. It is dangerous when it decides &lt;strong&gt;what&lt;/strong&gt;&lt;br&gt;
the work is done to.&lt;/p&gt;

&lt;p&gt;Twenty-two published Actors. Every one of them sets a &lt;code&gt;default&lt;/code&gt; somewhere. Ten of those defaults&lt;br&gt;
still name a target, and I had just removed an eleventh.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Actor&lt;/th&gt;
&lt;th&gt;field&lt;/th&gt;
&lt;th&gt;what it audits when the caller says nothing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;seo-ai-visibility-auditor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;startUrls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apify.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bulk-domain-checker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;domains&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apify.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;domain-availability-checker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;domains&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apify.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sitemap-checker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;domains&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apify.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tech-stack-detector&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;domains&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apify.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dead-link-checker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;domains&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docs.apify.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http-status-checker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;urls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apify.com/store&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pdf-inspector&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pdfUrls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a US tax form&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pdf-to-text-markdown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pdfUrls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same tax form&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hacker-news-link-rot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the Hacker News &lt;code&gt;topstories&lt;/code&gt; list&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Call any of the first nine with an empty input and you get a finished report. It covers Apify's own&lt;br&gt;
website, or a US tax form. Nothing in the response says it is a sample. An agent has a well-formed&lt;br&gt;
answer to a question nobody asked.&lt;/p&gt;

&lt;p&gt;Then I noticed the part that embarrassed me most. Nine of those ten fields also appear under&lt;br&gt;
&lt;code&gt;required&lt;/code&gt; in the same schema.&lt;/p&gt;

&lt;p&gt;I had been reading &lt;code&gt;required&lt;/code&gt; as a promise that the caller named the target. So I tested that&lt;br&gt;
reading. I called &lt;code&gt;seo-ai-visibility-auditor&lt;/code&gt; with an empty object.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; empty.json
apify call GuuMKUiWcaUUqGGhG &lt;span class="nt"&gt;--input-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;empty.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run succeeded and audited &lt;code&gt;apify.com&lt;/code&gt;. Here is what the platform stored as my input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"startUrls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://apify.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"crawlSite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checkBrokenLinks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxLinksToCheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"proxyConfiguration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"useApifyProxy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I sent &lt;code&gt;{}&lt;/code&gt;. The Actor received a target, and nothing rejected the call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.apify.com/platform/actors/development/actor-definition/input-schema/specification/v1" rel="noopener noreferrer"&gt;The specification&lt;/a&gt;&lt;br&gt;
treats the two settings as alternatives rather than as a pair. &lt;code&gt;required&lt;/code&gt; is for fields that "don't&lt;br&gt;
have a reasonable default". &lt;code&gt;default&lt;/code&gt; is passed by the platform whenever the caller omits the field,&lt;br&gt;
"via any means". Put both on one field and the second one decides. &lt;code&gt;required&lt;/code&gt; survives as a note to&lt;br&gt;
whoever is reading the form.&lt;/p&gt;

&lt;p&gt;The other twelve Actors are fine. &lt;code&gt;country&lt;/code&gt;, &lt;code&gt;outputFormats&lt;/code&gt;, &lt;code&gt;robotsAgent&lt;/code&gt;, &lt;code&gt;manifestGroups&lt;/code&gt; —&lt;br&gt;
omitting those does not invent work.&lt;/p&gt;

&lt;p&gt;One row in the table made me think harder. &lt;code&gt;hacker-news-link-rot&lt;/code&gt; defaults its &lt;code&gt;list&lt;/code&gt; field to&lt;br&gt;
&lt;code&gt;topstories&lt;/code&gt;, and that does pick the target. But the Actor has no other way in. An empty call has to&lt;br&gt;
mean something. It is also the one row I never marked &lt;code&gt;required&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the rule is not "never use &lt;code&gt;default&lt;/code&gt;". It is narrower than that:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A caller who says nothing must not receive results they cannot tell apart from results they asked&lt;br&gt;
for.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;hacker-news-link-rot&lt;/code&gt; satisfies that with one sentence in its description. The other nine did not&lt;br&gt;
satisfy it at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same mistake, four times, before an agent was involved
&lt;/h2&gt;

&lt;p&gt;Here is what stung. This was not a new class of error for me. It was the fourth time.&lt;/p&gt;

&lt;p&gt;Every audit Actor I have written has produced a confident finding that was really a gap in my own&lt;br&gt;
knowledge. Each time, the fix was the same shape: split one value into two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A rate limit that looked like deletion.&lt;/strong&gt; My Chrome extension auditor read 130 listings from&lt;br&gt;
Google's own sitemap, one every 600 milliseconds. Ninety-three came back as "the store has never&lt;br&gt;
heard of this ID". Every one of them was a healthy extension I had listed minutes earlier.&lt;/p&gt;

&lt;p&gt;Google serves its rate-limit interstitial as a redirect away from the store. A reader that only asks&lt;br&gt;
"did I land on a listing page?" sees exactly what a deleted extension looks like. The Actor now checks&lt;br&gt;
for that first. It never treats the interstitial as a fact about the extension. It stops the run&lt;br&gt;
instead of producing ninety-three more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A 404 that was an advertising server refusing.&lt;/strong&gt; My podcast auditor reported a 1.7% episode death&lt;br&gt;
rate. Nine of those failures came from one host, and all nine shows were running fine.&lt;/p&gt;

&lt;p&gt;A per-listener redirect service was serving that audio, and stitching in advertising as it went. It&lt;br&gt;
would not build a redirect for an automated request. So it answered 404, with the body &lt;code&gt;Missing&lt;br&gt;
redirect URL&lt;/code&gt;. I had a real death rate of 0.5% and a fake one three times larger.&lt;/p&gt;

&lt;p&gt;Those cases became &lt;code&gt;undetermined&lt;/code&gt;, not dead. The finding text says what I actually know:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Whether the audio is there cannot be established without behaving like a listener, which this&lt;br&gt;
Actor does not do.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;A licence warning on lodash.&lt;/strong&gt; GitHub could not match lodash's LICENSE to a standard licence, so it&lt;br&gt;
returned &lt;code&gt;NOASSERTION&lt;/code&gt;. My first version called that &lt;code&gt;license_non_standard&lt;/code&gt; and raised it.&lt;/p&gt;

&lt;p&gt;lodash, jQuery UI and UglifyJS all land there. They are ordinary MIT and BSD projects whose LICENSE&lt;br&gt;
carries an extra paragraph. A warning that fires on healthy rows does not add information. It buries&lt;br&gt;
the rows that matter. The code comment I left says it better than I can paraphrase:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Raising this would put a warning on healthy rows and bury the ones that matter.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;A severity I set before I measured anything.&lt;/strong&gt; I once shipped "no update in three years" as a high&lt;br&gt;
severity finding. Then I measured the base rate across 1,312 App Store apps. It is 18.4%.&lt;/p&gt;

&lt;p&gt;A finding that fires on one row in five describes the ecosystem. It does not describe a problem with&lt;br&gt;
your dependency. It sits at medium now.&lt;/p&gt;

&lt;p&gt;For the Shopify auditor I dropped the check entirely. Shopify publishes a launch date and no update&lt;br&gt;
date. Building the check anyway would have meant guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I ended up with
&lt;/h2&gt;

&lt;p&gt;Every one of these is the same rule, arrived at four times the slow way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Return "broken", "fine", and "I could not check" as three different values.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I used to justify that by saying an agent cannot tell the third from the second. My own trials say&lt;br&gt;
otherwise. Four agents pulled the distinction out of a &lt;code&gt;source&lt;/code&gt; field I had not documented. Two&lt;br&gt;
worked out on their own that a repository-only row never touches the registry.&lt;/p&gt;

&lt;p&gt;So the reason is not that agents cannot cope. It is what coping costs.&lt;/p&gt;

&lt;p&gt;Every ambiguity you leave in your output is work you have handed to the caller. Sometimes they pay it&lt;br&gt;
in a second run against a rate-limited API. Sometimes they pay it by writing a paragraph explaining&lt;br&gt;
your tool's quirk to their user. Sometimes they pay it silently, by throwing away three quarters of&lt;br&gt;
what you charged them for.&lt;/p&gt;

&lt;p&gt;None of that shows up as an error. It shows up as your Actor being slightly more expensive and&lt;br&gt;
slightly less trusted than the one next to it.&lt;/p&gt;

&lt;p&gt;The input schema is the other half. If a field can change what gets audited, the caller must set it.&lt;br&gt;
Silence has to mean silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in your own schema
&lt;/h2&gt;

&lt;p&gt;Five things, in the order that cost me the most time.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Search your schema for &lt;code&gt;"default"&lt;/code&gt;.&lt;/strong&gt; For each hit, ask what happens when an API caller omits
that field. If the answer changes &lt;em&gt;what&lt;/em&gt; the Actor works on, move it to &lt;code&gt;prefill&lt;/code&gt;. Listing the
field under &lt;code&gt;required&lt;/code&gt; will not do this for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call your Actor with the minimum viable input, then read the stored &lt;code&gt;INPUT&lt;/code&gt; record.&lt;/strong&gt; Do not
read the input you sent. Read what the platform saved. That is what your code receives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make sure an empty call fails.&lt;/strong&gt; If your Actor can always find something to do, you cannot tell
an empty call from a real one. I checked what that costs, because the platform runs published
Actors on its own schedule. Twelve of my twenty-two already have nothing to do on an empty call,
three of them because a &lt;code&gt;required&lt;/code&gt; field carries no &lt;code&gt;default&lt;/code&gt; at all. All twenty-two show zero
failed runs across 159 platform runs in the last thirty days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write your limits into &lt;code&gt;description&lt;/code&gt;, not the README.&lt;/strong&gt; The description is what an agent reads.
Mine now says why &lt;code&gt;pyproject.toml&lt;/code&gt; is unsupported: half-parsing a manifest produces findings about
dependencies you do not have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand the tool to an agent and read what it says about you.&lt;/strong&gt; This found more than my own review
did. An agent that works around your quirk will usually explain the quirk to its user, in writing,
in the answer. That paragraph is a free bug report. It is also what your Actor looks like to a
prospective user.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The full source for the Actor in this article is on GitHub:&lt;br&gt;
&lt;a href="https://github.com/ai-q-labs/github-repository-audit" rel="noopener noreferrer"&gt;&lt;code&gt;ai-q-labs/github-repository-audit&lt;/code&gt;&lt;/a&gt;. That is&lt;br&gt;
the code behind the published Actor at&lt;br&gt;
&lt;a href="https://apify.com/aiqlabs/github-repository-audit" rel="noopener noreferrer"&gt;&lt;code&gt;apify.com/aiqlabs/github-repository-audit&lt;/code&gt;&lt;/a&gt;. It&lt;br&gt;
includes the input schema this article is about, 52 unit tests, and a live check against the real&lt;br&gt;
GitHub, npm and PyPI APIs.&lt;/p&gt;

&lt;p&gt;Clone it, run &lt;code&gt;npm install&lt;/code&gt;, then &lt;code&gt;npm test&lt;/code&gt; for the unit tests or &lt;code&gt;npm run test:live&lt;/code&gt; for the live&lt;br&gt;
check. The live check needs no key, but GitHub allows unauthenticated callers 60 requests an hour, so&lt;br&gt;
set &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; if you run it more than once.&lt;/p&gt;

&lt;p&gt;Full-size versions of every screenshot in this article sit in the same repository, under&lt;br&gt;
&lt;code&gt;docs/screenshots&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>automation</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My Actor ranked #1 in an agent's search. No one else's agent could see it at all.</title>
      <dc:creator>Ai-Q Labs</dc:creator>
      <pubDate>Fri, 18 Sep 2026 00:07:12 +0000</pubDate>
      <link>https://dev.to/apify/my-actor-ranked-1-in-an-agents-search-no-one-elses-agent-could-see-it-at-all-2jf1</link>
      <guid>https://dev.to/apify/my-actor-ranked-1-in-an-agents-search-no-one-elses-agent-could-see-it-at-all-2jf1</guid>
      <description>&lt;h1&gt;
  
  
  My Actor ranked #1 in an agent's search. No one else's agent could see it at all.
&lt;/h1&gt;

&lt;p&gt;Four days ago I published a number I was pleased with. I had measured how often an AI agent finds one of my 23 Actors when it searches the &lt;a href="https://apify.com/store" rel="noopener noreferrer"&gt;Apify Store&lt;/a&gt; through the &lt;a href="https://docs.apify.com/platform/integrations/mcp" rel="noopener noreferrer"&gt;Apify MCP server&lt;/a&gt;. The answer came out at roughly one query in five, and on &lt;code&gt;sitemap checker&lt;/code&gt; my Actor came back first.&lt;/p&gt;

&lt;p&gt;Both figures were wrong. On the queries I re-tested this morning, an agent that is not mine finds my Actors zero times out of six.&lt;/p&gt;

&lt;p&gt;The arithmetic was fine. The problem was the connection I measured through, and it is a problem that any Actor author can have without noticing, because the platform gives you no obvious way to measure it any other way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement I trusted
&lt;/h2&gt;

&lt;p&gt;My script for that article built one URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://mcp.apify.com/?token=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it called &lt;code&gt;search-actors&lt;/code&gt; with a keyword, paged through the results and recorded where my Actor landed. &lt;code&gt;sitemap checker&lt;/code&gt; put it at rank 1. &lt;code&gt;PDF tables&lt;/code&gt; put it at rank 2. I wrote both numbers down and reasoned from them for a week.&lt;/p&gt;

&lt;p&gt;I never asked whose search that was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same query, with the token and without
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;search-actors&lt;/code&gt; needs a token, so I could not run it anonymously. The Store also has a plain REST endpoint that serves the same shelf, and that one takes the &lt;code&gt;Authorization&lt;/code&gt; header or does without it. So I ran the same query twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// store-auth-diff.mjs — is my Actor in the results, or only in *my* results?&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;withAuth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.apify.com/v2/store?search=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;limit=100`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;withAuth&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aiqlabs/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;returned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mineAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sitemap checker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf table extractor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;github repository audit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;  auth:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;  anon:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its output on 16 August:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sitemap checker
  auth: { total: 339, returned: 78, mineAt: 1 }
  anon: { total: 339, returned: 77, mineAt: null }
pdf table extractor
  auth: { total: 757, returned: 86, mineAt: 2 }
  anon: { total: 757, returned: 84, mineAt: null }
github repository audit
  auth: { total: 417, returned: 71, mineAt: 1 }
  anon: { total: 417, returned: 69, mineAt: null }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the columns in order. &lt;code&gt;total&lt;/code&gt; is the same number with and without the token — 339 stays 339. The count of items actually returned is one to three higher when the token is present. The extra items are mine.&lt;/p&gt;

&lt;p&gt;I ran it over five queries. With the token my Actors sit at ranks 1, 2, 1, 1 and 5. Without it, none of them is anywhere in the first hundred results.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F57sn0imq75s26vy4c76p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F57sn0imq75s26vy4c76p.png" alt="Apify Store search results compared with and without an API token, across five queries. The total count is identical in both calls — 339, 757, 417, 5102, 3189 — while the number of items returned rises by one to three when the token is present. With the token the author's Actors rank 1, 2, 1, 1 and 5; without it they are absent from all one hundred results." width="800" height="237"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same endpoint, same query, one HTTP header apart.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP tool reads the same shelf
&lt;/h2&gt;

&lt;p&gt;That only bears on the earlier article if &lt;code&gt;search-actors&lt;/code&gt; ranks the same way the REST endpoint does. It does. Six queries through both, comparing the top five:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;query&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;search-actors&lt;/code&gt; (token)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;/v2/store&lt;/code&gt; (token)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;/v2/store&lt;/code&gt; (no token)&lt;/th&gt;
&lt;th&gt;top 5 identical&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sitemap checker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PDF tables&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GitHub repository&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent to 30&lt;/td&gt;
&lt;td&gt;#40&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;broken links&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent to 30&lt;/td&gt;
&lt;td&gt;#29&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RSS feed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent to 30&lt;/td&gt;
&lt;td&gt;#47&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tech stack&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The top five agree on every query. Where my Actor appears in both channels, the rank is the same integer. So the first place I published — first place on &lt;code&gt;sitemap checker&lt;/code&gt;, for an AI agent — was first place for an agent carrying my token. There is one such agent and I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One query parameter says why
&lt;/h2&gt;

&lt;p&gt;At this point I assumed the token was doing something to the ranking. It is not. The Store endpoint documents a parameter that tells you exactly what is happening, and you can use it without any credentials at all.&lt;/p&gt;

&lt;p&gt;From the &lt;a href="https://docs.apify.com/api/v2/store-get" rel="noopener noreferrer"&gt;&lt;code&gt;GET /v2/store&lt;/code&gt; reference&lt;/a&gt;, on &lt;code&gt;includeUnrunnableActors&lt;/code&gt;, describing what the default excludes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Actors from developers who haven't passed KYC, or full-permission Actors without a large user base&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I ran every query anonymously, twice — once plain, once with that flag on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// unrunnable-probe.mjs — no credentials anywhere in this file&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;github repository audit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.apify.com/v2/store?search=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;limit=100`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;includeUnrunnableActors=true`&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aiqlabs/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unrunnable ON &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default      &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;total:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mine at:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;—&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;default       total: 417 mine at: —
unrunnable ON total: 502 mine at: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across five queries, unauthenticated both times:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;query&lt;/th&gt;
&lt;th&gt;default&lt;/th&gt;
&lt;th&gt;&lt;code&gt;includeUnrunnableActors=true&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sitemap checker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent, total 339&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;#3&lt;/strong&gt;, total &lt;strong&gt;352&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pdf table extractor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent, total 757&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;#2&lt;/strong&gt;, total &lt;strong&gt;877&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;github repository audit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent, total 417&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;#1&lt;/strong&gt;, total &lt;strong&gt;502&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google play audit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent, total 5,102&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;#1&lt;/strong&gt;, total &lt;strong&gt;5,386&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http status checker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absent, total 3,189&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;#6&lt;/strong&gt;, total &lt;strong&gt;3,379&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fji0r2ef8vuokniqzap0k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fji0r2ef8vuokniqzap0k.png" alt="Apify Store search run twice without any credentials, with and without includeUnrunnableActors=true. In the default call the author's Actors are absent on all five queries. With the flag on they return at ranks 3, 2, 1, 1 and 6, and the total result count rises from 339 to 352, 757 to 877, 417 to 502, 5102 to 5386 and 3189 to 3379." width="800" height="245"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Both calls are unauthenticated. The only difference is one documented query parameter.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The totals move. Between 13 and 284 more Actors enter each result set, and mine come back near the top of them. My Actors were never ranked badly. They were filtered out before ranking, for everyone except me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which of the two exclusions is mine
&lt;/h2&gt;

&lt;p&gt;The documented filter covers two groups, and it is worth knowing which one you are in, because only one of them is fixed by paperwork. The permission level of any public Actor is readable without a token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.apify.com/v2/acts/aiqlabs~sitemap-checker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorPermissionLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPublic&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// LIMITED_PERMISSIONS true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All five Actors I checked come back &lt;code&gt;LIMITED_PERMISSIONS&lt;/code&gt;, so the full-permission half of the filter does not apply to them. That leaves the other half, and Apify Console states it plainly:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr3p4yyh1gh1pndbvdob6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr3p4yyh1gh1pndbvdob6.png" alt="A notice in Apify Console reading " start="" width="795" height="37"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Insights → Payouts, 16 August 2026. Billing details are registered on the same screen; the identity check is not.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The document I hold keeps being rejected by the verification vendor, which is an open thread with Apify support and not this article's subject.&lt;/p&gt;

&lt;p&gt;What matters here is the shape of it. &lt;strong&gt;An incomplete account setting removed my Actors from every search except my own, and eleven days of work on titles, categories and output schemas went into Actors that were not in the result set while I was tuning them.&lt;/strong&gt; Every one of those changes was aimed at &lt;a href="https://docs.apify.com/actors/publishing/quality-score" rel="noopener noreferrer"&gt;Actor quality score&lt;/a&gt;, which Apify documents as the ranking input for both Store search and &lt;code&gt;search-actors&lt;/code&gt;. Ranking inputs do nothing for a row that never reaches the ranking stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actor works. That was never the problem.
&lt;/h2&gt;

&lt;p&gt;It is worth being clear about what is and is not broken here, because "invisible in search" and "unusable as a tool" are different failures and only one of them applies.&lt;/p&gt;

&lt;p&gt;Name the Actor in the connection string and an agent picks it up as a tool immediately. Mine is pinned into my editor's MCP config, so I asked the agent to audit two well-known repositories. It called the Actor itself, waited for the run, and read the dataset back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;run m3PNCMD8HAAIGFpAq   SUCCEEDED in 2.402s   0.0027 compute units   2 items
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;requested&lt;/th&gt;
&lt;th&gt;resolved to&lt;/th&gt;
&lt;th&gt;archived&lt;/th&gt;
&lt;th&gt;moved&lt;/th&gt;
&lt;th&gt;last push&lt;/th&gt;
&lt;th&gt;risk&lt;/th&gt;
&lt;th&gt;issues&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;facebook/create-react-app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;react/create-react-app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;547 days&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;repo_moved&lt;/code&gt;, &lt;code&gt;stale_no_push&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;babel/babel-eslint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;babel/babel-eslint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;1,823 days&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;critical&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repo_archived&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two and a half seconds, a fraction of a cent, and the agent had something it could act on: one dependency whose repository has quietly moved owner while the old URL still answers, and one that is archived outright. That is the whole point of exposing an Actor over MCP — the agent reaches for it mid-task instead of asking me to go and look.&lt;/p&gt;

&lt;p&gt;So the tool definition, the input schema and the output fields all do their job. An agent that has this Actor uses it fine. The failure is one step earlier and entirely invisible from here: an agent that does not already have the name will not find it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your own MCP session cannot tell you
&lt;/h2&gt;

&lt;p&gt;The obvious control is to connect to the MCP server without a token and search again. That is not available:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -X POST https://mcp.apify.com/ -H 'content-type: application/json' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
{"error":"invalid_token","error_description":"Missing or invalid access token. Pass an Apify API
token in the Authorization: Bearer &amp;lt;token&amp;gt; header. ..."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTTP 401, with no token and with an empty &lt;code&gt;?token=&lt;/code&gt; alike. The server speaks &lt;a href="https://www.jsonrpc.org/specification" rel="noopener noreferrer"&gt;JSON-RPC&lt;/a&gt; over HTTP, as &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; requires, and every session belongs to somebody.&lt;/p&gt;

&lt;p&gt;This is the part I would ask you to sit with, because it is structural and not personal. If you publish an Actor and want to know whether an agent can find it, the natural move is to connect your own client and search. That test is authenticated by construction. It will show you your Actor whether or not anyone else can see it. Your test passes, and it passes for the one reason that cannot generalise.&lt;/p&gt;

&lt;p&gt;Nothing in the MCP documentation warned me about this, and I do not think it should have to. Returning an author their own Actors is a reasonable thing for a store to do — it is what makes testing an unpublished or unreviewed Actor possible at all. The gap is not in the behaviour. It is that the only search surface an Actor author naturally reaches for is the one that cannot be run anonymously.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four-line check to run before you tune anything
&lt;/h2&gt;

&lt;p&gt;No token, no dependencies, no cost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// visible.mjs — run: node visible.mjs your-username sitemap checker&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="s2"&gt;`https://api.apify.com/v2/store?search=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;limit=100&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;absent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`#&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;  (total &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;as everyone sees it :&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;including filtered  :&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;includeUnrunnableActors=true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run against my own account and against a publisher who is not filtered, on the same query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node visible.mjs aiqlabs sitemap checker
as everyone sees it : absent  (total 339)
including filtered  : #3  (total 352)

$ node visible.mjs automation-lab sitemap checker
as everyone sees it : #1  (total 339)
including filtered  : #6  (total 352)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different diagnoses from the same two lines. Mine is absent until the filter comes off. The other publisher is first without it, and slips to sixth with it — which is what a healthy result looks like, because turning the filter on adds thirteen competitors to that shelf.&lt;/p&gt;

&lt;p&gt;So: if the first line says &lt;code&gt;absent&lt;/code&gt; and the second gives you a rank, your Actor is being filtered out of search for every user but you, and no amount of ranking work will change that until the filter lifts. If both lines say &lt;code&gt;absent&lt;/code&gt;, you have an ordinary ranking problem. If the first line gives you a rank, you are visible and can trust the numbers you measure.&lt;/p&gt;

&lt;p&gt;I would rather have run those four lines on 5 August than on 16 August.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong
&lt;/h2&gt;

&lt;p&gt;I published a reachability figure and a rank without asking whose view produced them. The script that produced them had &lt;code&gt;?token=&lt;/code&gt; written into the URL, by me.&lt;/p&gt;

&lt;p&gt;It is the second time in two weeks that I have measured my own visibility from inside my own session. The first was a rank I read off the Store web UI in a browser I was logged into. I caught that one, corrected it privately, and then made the same mistake again in a script — which tells me the lesson is not "be careful with browsers" but something duller: &lt;strong&gt;if a measurement can only be taken while authenticated, the number it returns is a statement about you, not about your users.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The earlier article's other findings survive. A default MCP connection really does hand an agent eleven tools with a single Actor among them; &lt;code&gt;limit&lt;/code&gt; really does default to 5 and cap at 10; the result pages really are ragged, so a short page is not the end of the list; and broadening a keyword really does push my Actor down — that comparison sits inside one channel, so its direction holds even though both ranks are token-local.&lt;/p&gt;

&lt;p&gt;What has to be withdrawn is the headline. One query in five is what I could see. For anyone else's agent, on the queries I re-tested, it was none of them.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>agents</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Actor should never see my credentials: the security case for MCP connectors</title>
      <dc:creator>Jeffrey Turov</dc:creator>
      <pubDate>Thu, 17 Sep 2026 22:34:36 +0000</pubDate>
      <link>https://dev.to/apify/your-actor-should-never-see-my-credentials-the-security-case-for-mcp-connectors-3ief</link>
      <guid>https://dev.to/apify/your-actor-should-never-see-my-credentials-the-security-case-for-mcp-connectors-3ief</guid>
      <description>&lt;p&gt;Every automation platform has the same dirty secret: the easiest way to connect a third-party service is to paste a token into an input field. It works on the first try. It is also how credentials end up in places nobody intended.&lt;/p&gt;

&lt;p&gt;I learned this building lead-generation Actors on Apify. My first working version took a GitHub token as an Actor input. It shipped fast, and it was wrong. This piece is about the threat model behind that mistake, and how MCP connectors invert it: the Actor I run never sees a credential at all.&lt;/p&gt;

&lt;p&gt;Everything below comes from real builds and real run logs, including the failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threat model nobody writes down
&lt;/h2&gt;

&lt;p&gt;Passing a service token as an Actor input creates three leak paths, and all three are boring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run logs.&lt;/strong&gt; Inputs are echoed, logged, and retained. Anyone with whom you share a run (a teammate, a support ticket, a screenshot in a bug report) potentially shares the token with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared input JSON.&lt;/strong&gt; Actors get cloned, forked, and re-run from saved inputs. A token inside an input object travels with every copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party Actor code.&lt;/strong&gt; The moment you run code you did not write (a Store Actor, a fork, a colleague's experiment), any credential you hand it is only as safe as that code's worst &lt;code&gt;console.log&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what these have in common: none of them require an attacker. The leak vector is the architecture itself. The token is simply in more places than it needs to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The inversion: credentials live with the connector, not the code
&lt;/h2&gt;

&lt;p&gt;Apify's &lt;a href="https://docs.apify.com/integrations/mcp-connectors" rel="noopener noreferrer"&gt;MCP connectors&lt;/a&gt; restructure where the secret sits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You authorize the third-party service (GitHub, Notion, Slack) &lt;strong&gt;once&lt;/strong&gt;, in Apify Console under Settings, Integrations. The OAuth credential is stored with the connector, on the platform side.&lt;/li&gt;
&lt;li&gt;At run time, the Actor receives a &lt;strong&gt;connector ID&lt;/strong&gt;: an opaque string like &lt;code&gt;ebw4ThD4cQbEKzC2l&lt;/code&gt;. It is not a token, and it is useless outside the platform.&lt;/li&gt;
&lt;li&gt;The Actor talks to the &lt;strong&gt;Apify MCP proxy&lt;/strong&gt; at &lt;code&gt;${ACTOR_MCP_CONNECTOR_BASE_URL}/&amp;lt;connectorId&amp;gt;&lt;/code&gt;, authenticating with the run's own &lt;code&gt;APIFY_TOKEN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The proxy enforces the tool permissions the Actor declared in its input schema.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical consequence: you can publish the Actor's source, share its runs, and paste its input JSON into a forum. There is nothing in any of those artifacts worth stealing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Least privilege is declared, not hoped for
&lt;/h2&gt;

&lt;p&gt;The second half of the model is the &lt;code&gt;mcpServers&lt;/code&gt; rule in the input schema. This is where the Actor states which tools it needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"githubConnector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GitHub connector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcpConnector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"create_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"push_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"update_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"write_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"commit_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"readOnly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two layers constrain what the Actor can do: what the connector exposed at authorization time, and what the schema declares. The intersection is enforced by the proxy at run time, not by documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verified in a real run log.&lt;/strong&gt; My Actor's schema used the declaration above. When it called &lt;code&gt;list_tools()&lt;/code&gt; through the proxy, it saw exactly 7 tools: &lt;code&gt;create_branch&lt;/code&gt;, &lt;code&gt;create_or_update_file&lt;/code&gt;, &lt;code&gt;create_pull_request&lt;/code&gt;, &lt;code&gt;create_repository&lt;/code&gt;, &lt;code&gt;push_files&lt;/code&gt;, &lt;code&gt;update_pull_request&lt;/code&gt;, &lt;code&gt;update_pull_request_branch&lt;/code&gt;. Nothing else existed as far as the Actor could tell. The constraint layer is not a promise on a marketing page; it is observable behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;p&gt;A security argument that only lists strengths is marketing. Three limits I hit or verified:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The connector's tool set is frozen at authorization time.&lt;/strong&gt; When you authorize a connector, the platform discovers the available tools once. If the upstream server later adds tools you want, you re-authorize. The set does not refresh itself. I lost time to this before reading it properly in the docs: it is "Layer 1" of their two-layer model, and it is by design (a silent expansion of your Actor's powers would be a security hole, not a feature).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The run still authenticates with a token: the starter's &lt;code&gt;APIFY_TOKEN&lt;/code&gt;.&lt;/strong&gt; The proxy trusts whoever started the run. That means access control moves to &lt;em&gt;who can start your Actor with your connector&lt;/em&gt;, which is an Apify permissions question, not a code question. For Actors you share or publish, treat connector-enabled inputs as capability grants and review who can run what.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Connectors do not sanitize your payloads.&lt;/strong&gt; The proxy enforces &lt;em&gt;which tools&lt;/em&gt; the Actor calls, not &lt;em&gt;what data&lt;/em&gt; flows through them. An Actor scraping personal data and pushing it to your repo is still your compliance problem. The connector solves credential custody, not data governance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls from real runs
&lt;/h2&gt;

&lt;p&gt;Three failures I actually hit while building on this model, because they cost me runs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SDK drift.&lt;/strong&gt; The MCP Python SDK docs show &lt;code&gt;streamable_http_client&lt;/code&gt; unpacking into three values (&lt;code&gt;read, write, _&lt;/code&gt;). The version my Docker image installed yields two. &lt;code&gt;ValueError: not enough values to unpack&lt;/code&gt;. Indexing the tuple (&lt;code&gt;streams[0], streams[1]&lt;/code&gt;) works across versions. Same class of issue: the tool result error flag is &lt;code&gt;result.is_error&lt;/code&gt; (snake_case), not the &lt;code&gt;isError&lt;/code&gt; casing the TypeScript-flavored docs suggest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default branch assumptions.&lt;/strong&gt; &lt;code&gt;create_or_update_file&lt;/code&gt; failed with &lt;code&gt;Branch main not found&lt;/code&gt;: my repo's default is &lt;code&gt;master&lt;/code&gt;. The tool does not fall back to the repository default; it fails loudly, which is the correct behavior for a security boundary. Detect and retry with the other common name, or pass the branch explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write-only connectors and the SHA problem.&lt;/strong&gt; Updating an existing file on GitHub requires its current blob SHA. My write-scoped connector exposed no &lt;code&gt;get_file_contents&lt;/code&gt; to fetch it. Rather than widening permissions, I switched the pipeline to immutable, timestamped snapshots: each run writes a new dated file, and Git itself becomes the history. The least-privilege constraint pushed me to a better design, which is what least privilege is supposed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use what
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Token as input&lt;/th&gt;
&lt;th&gt;MCP connector&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local throwaway script, one run, you watch it&lt;/td&gt;
&lt;td&gt;Fine&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actor you will run on a schedule for weeks&lt;/td&gt;
&lt;td&gt;Leak path&lt;/td&gt;
&lt;td&gt;Right answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actor you publish or share&lt;/td&gt;
&lt;td&gt;Irresponsible&lt;/td&gt;
&lt;td&gt;Right answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running someone else's Actor with your services&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;td&gt;The only sane option&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the one that convinced me. If you consume third-party Actors, connectors are the only way to grant access to your services where a sloppy or malicious &lt;code&gt;print()&lt;/code&gt; cannot exfiltrate your credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Security models for automation are usually about adding vigilance: rotate tokens, restrict scopes, audit logs. MCP connectors remove the attack surface instead: the credential never enters the Actor's world, so it cannot leak from it. You trade a small amount of convenience (one authorization step in Console, a frozen tool set) for the ability to share runs, publish source, and run untrusted code without a knot in your stomach.&lt;/p&gt;

&lt;p&gt;The working example this article is drawn from is public: &lt;a href="https://github.com/jeffreyturov-dev/apify-scraping-toolbox" rel="noopener noreferrer"&gt;maps-to-stack on GitHub&lt;/a&gt;, and the build walkthrough with the scraping pitfalls is &lt;a href="https://dev.to/apify/from-scraper-to-stack-pushing-google-maps-leads-straight-into-github-with-mcp-connectors-43f3"&gt;on dev.to&lt;/a&gt;. Connector documentation: &lt;a href="https://docs.apify.com/integrations/mcp-connectors" rel="noopener noreferrer"&gt;docs.apify.com/integrations/mcp-connectors&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Build log details verified against run logs from August 2026: tool filtering (7 tools), branch fallback, SDK unpacking, and the immutable-snapshot pattern all occurred in real runs of the maps-to-stack Actor.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apify</category>
      <category>mcp</category>
      <category>security</category>
      <category>automation</category>
    </item>
    <item>
      <title>Investor prospecting lists from PitchBook and LinkedIn data</title>
      <dc:creator>Crawler Bros</dc:creator>
      <pubDate>Thu, 17 Sep 2026 05:21:25 +0000</pubDate>
      <link>https://dev.to/apify/investor-prospecting-lists-from-pitchbook-and-linkedin-data-an1</link>
      <guid>https://dev.to/apify/investor-prospecting-lists-from-pitchbook-and-linkedin-data-an1</guid>
      <description>&lt;h2&gt;
  
  
  Investor prospecting lists from PitchBook and LinkedIn data
&lt;/h2&gt;

&lt;p&gt;Fundraising research usually means either paying for a PitchBook or Crunchbase seat the founder only needs for a few months, or manually building a spreadsheet from investor websites and LinkedIn one profile at a time. Neither is a great use of the limited time founders have during an active raise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Investor prospecting with public data&lt;/strong&gt; means joining public investor profile data with public LinkedIn partner profiles into one targeted, sector- and stage-matched list, instead of paying for a full research-platform seat or building the list one profile at a time by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;Use PitchBook Investors Scraper to collect public investor profiles — firm name, focus areas, and stated investment criteria — without a PitchBook subscription. Use LinkedIn Profile Scraper to enrich individual partner or associate profiles with role, background, and current firm. Combine both into a prospecting list segmented by sector and stage fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The investor prospecting stack
&lt;/h2&gt;

&lt;p&gt;The stack uses two CrawlerBros Actors:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/crawlerbros/pitchbook-investors-scraper" rel="noopener noreferrer"&gt;PitchBook Investors Scraper&lt;/a&gt;&lt;/strong&gt; for public investor profiles without a subscription.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/crawlerbros/linkedin-profile-scraper" rel="noopener noreferrer"&gt;LinkedIn Profile Scraper&lt;/a&gt;&lt;/strong&gt; for public LinkedIn profile information without login.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fim6s6ee51b4a7zz2dnut.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fim6s6ee51b4a7zz2dnut.png" alt="PitchBook Investors Scraper Actor page on Apify, showing profile URL input fields used for public investor profile collection" width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The workflow:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;&lt;br&gt;
Sector and stage criteria&lt;br&gt;
    -&amp;gt; PitchBook Investors Scraper (firm-level investor profiles)&lt;br&gt;
    -&amp;gt; LinkedIn Profile Scraper (partner-level enrichment)&lt;br&gt;
    -&amp;gt; segmented investor prospecting list&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key facts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Firm-level data answers "who invests in this"; partner-level data answers "who do I email."&lt;/strong&gt; Both are needed for outreach that isn't generic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start from known profile URLs, not broad category browsing.&lt;/strong&gt; Both Actors work best against specific, already-identified profile pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Focus fields go stale.&lt;/strong&gt; A firm's stated sector/stage focus can change; re-verify before outreach, not just before building the list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fit beats volume.&lt;/strong&gt; A well-matched list of 20-50 firms consistently outperforms an unfiltered list of hundreds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Actor configuration that matters
&lt;/h2&gt;

&lt;p&gt;For PitchBook Investors Scraper, start from firm or investor profile URLs discovered through sector research rather than trying to enumerate an entire investor category in one run — public profile pages are the reliable input; broad category browsing is not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;json&lt;br&gt;
{&lt;br&gt;
  "profileUrls": [&lt;br&gt;
    "https://pitchbook.com/profiles/investor/example-1",&lt;br&gt;
    "https://pitchbook.com/profiles/investor/example-2"&lt;br&gt;
  ],&lt;br&gt;
  "includeInvestmentCriteria": true&lt;br&gt;
}&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;For LinkedIn Profile Scraper, feed it the specific partner or associate profile URLs found through the PitchBook data or firm websites, since public profile scraping works best against known URLs rather than broad search.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the output looks like
&lt;/h2&gt;

&lt;p&gt;The PitchBook row needs firm name, focus sectors, stage focus, and stated check size when available. The LinkedIn row needs name, title, current firm, and background summary. Joining the two gives a prospecting row that answers both "does this firm invest in what I'm building" and "who specifically should I reach out to."&lt;/p&gt;

&lt;h2&gt;
  
  
  Use case 1: targeted seed and Series A outreach
&lt;/h2&gt;

&lt;p&gt;A founder raising a specific round size in a specific sector can filter PitchBook profiles for matching stage and sector focus, then use LinkedIn enrichment to identify the right partner to reach rather than sending a cold email to a generic firm inbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use case 2: warm-intro path mapping
&lt;/h2&gt;

&lt;p&gt;Once a target investor list exists, cross-referencing partner LinkedIn backgrounds against a founder's own network (previous companies, schools, mutual connections) helps prioritize which firms are reachable through a warm introduction versus cold outreach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use case 3: competitive fundraising intelligence
&lt;/h2&gt;

&lt;p&gt;Tracking which investors a competitor has raised from, where that's publicly known, helps a founder understand which firms are already active in the category and how to differentiate outreach messaging for firms likely to be comparing the two companies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production notes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start from known profile URLs, not broad scraping.&lt;/strong&gt; Both Actors work best against specific, already-identified profile pages rather than attempting to enumerate an entire category of investors in one pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Respect what's public.&lt;/strong&gt; Only collect information visible on public profile pages. Avoid combining this with any data source that would require login credentials or private access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-verify stage and sector focus before outreach.&lt;/strong&gt; Investor focus areas change; a profile scraped months ago may not reflect a firm's current thesis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prioritize fit over volume.&lt;/strong&gt; A list of 20 well-matched investors outperforms a list of 200 firms scraped without sector or stage filtering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firm-level data alone produces low response rates.&lt;/strong&gt; A firm-level list without partner-level context means outreach goes to generic inboxes. Adding LinkedIn partner enrichment makes outreach targeted enough to get meaningfully better response rates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Data freshness&lt;/th&gt;
&lt;th&gt;Partner-level detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PitchBook/Crunchbase subscription&lt;/td&gt;
&lt;td&gt;Expensive, ongoing&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual research&lt;/td&gt;
&lt;td&gt;Free, very slow&lt;/td&gt;
&lt;td&gt;Depends on effort&lt;/td&gt;
&lt;td&gt;Inconsistent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apify PitchBook + LinkedIn pipeline&lt;/td&gt;
&lt;td&gt;Pay per run&lt;/td&gt;
&lt;td&gt;On demand&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Check the current Pricing tab on each Actor page before running at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Can Apify replace a PitchBook subscription?
&lt;/h2&gt;

&lt;p&gt;It can replace the workflow of researching public investor profiles for a specific list; it doesn't replicate PitchBook's full paid database and analytics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is scraping public LinkedIn profiles allowed?
&lt;/h2&gt;

&lt;p&gt;LinkedIn Profile Scraper collects publicly visible profile information without login; always review current platform terms before large-scale collection.&lt;/p&gt;

&lt;h2&gt;
  
  
  How big should a first investor list be?
&lt;/h2&gt;

&lt;p&gt;Most founders get better results from a well-matched list of 20-50 firms than an unfiltered list of hundreds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the fastest way to try this?
&lt;/h2&gt;

&lt;p&gt;Pick 10 investor firms whose public focus matches your sector and stage, then run PitchBook Investors Scraper and LinkedIn Profile Scraper against them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;Pick 10 investor firms whose public focus matches your sector and stage. Run &lt;a href="https://apify.com/crawlerbros/pitchbook-investors-scraper" rel="noopener noreferrer"&gt;PitchBook Investors Scraper&lt;/a&gt; against their profile pages, then use &lt;a href="https://apify.com/crawlerbros/linkedin-profile-scraper" rel="noopener noreferrer"&gt;LinkedIn Profile Scraper&lt;/a&gt; to find the right partner at each. A short, well-matched list beats a long, generic one.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>startup</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Facebook Marketplace intelligence: resale pricing and comment sentiment</title>
      <dc:creator>Crawler Bros</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:15:01 +0000</pubDate>
      <link>https://dev.to/apify/facebook-marketplace-intelligence-resale-pricing-and-comment-sentiment-4pdk</link>
      <guid>https://dev.to/apify/facebook-marketplace-intelligence-resale-pricing-and-comment-sentiment-4pdk</guid>
      <description>&lt;h2&gt;
  
  
  Facebook Marketplace intelligence: resale pricing and comment sentiment
&lt;/h2&gt;

&lt;p&gt;Facebook Marketplace is one of the largest resale and local-commerce platforms, and one of the least monitored. Most price-intelligence tools skip it entirely because it's local, listing-heavy, and constantly turning over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Facebook Marketplace intelligence&lt;/strong&gt; means combining public listing data with public comment data to price resale inventory, spot unauthorized brand resale, and read how a community actually reacts to a local post — instead of relying on manual browsing or reaction-emoji counts alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;Use Facebook Marketplace Scraper to collect listing prices, condition, and seller data for a product category or location. Use Facebook Comments Scraper to pull public reactions on posts, whether that's a Marketplace listing, a local business page post, or a community group thread. Combine both to price resale inventory and read local sentiment in the same workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Marketplace intelligence stack
&lt;/h2&gt;

&lt;p&gt;The stack uses two CrawlerBros Actors:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/crawlerbros/facebook-marketplace-scraper" rel="noopener noreferrer"&gt;Facebook Marketplace Scraper&lt;/a&gt;&lt;/strong&gt; for listing data, pricing, and seller information.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/crawlerbros/facebook-comments-scraper" rel="noopener noreferrer"&gt;Facebook Comments Scraper&lt;/a&gt;&lt;/strong&gt; for public comments on posts and videos with metadata.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmos4fihe565xmjd3abve.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmos4fihe565xmjd3abve.png" alt="Facebook Marketplace Scraper Actor page on Apify, showing category, location, and result-limit input fields used for resale price tracking" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The workflow:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;&lt;br&gt;
Product category or location&lt;br&gt;
    -&amp;gt; Facebook Marketplace Scraper (listing prices, condition, seller)&lt;br&gt;
    -&amp;gt; Facebook Comments Scraper (reactions on flagged listings or posts)&lt;br&gt;
    -&amp;gt; resale price and sentiment dataset&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key facts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Location beats keyword.&lt;/strong&gt; Marketplace is a local marketplace by design — a keyword search without a location filter returns inconsistent results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Condition explains price, not irrationality.&lt;/strong&gt; Two identical-looking listings at different prices are usually explained by condition, not by one seller being unreasonable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Listing velocity beats a single price.&lt;/strong&gt; A category with fast-turning listings at a given price point signals real demand better than a handful of stale unsold listings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Public" has a specific meaning here.&lt;/strong&gt; Marketplace listings and public post comments qualify; private group content doesn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Actor configuration that matters
&lt;/h2&gt;

&lt;p&gt;For Facebook Marketplace Scraper, scope by category and location rather than a broad keyword search — Marketplace results are heavily location-weighted, so a keyword search without a location filter returns inconsistent results.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;json&lt;br&gt;
{&lt;br&gt;
  "category": "furniture",&lt;br&gt;
  "location": "Austin, TX",&lt;br&gt;
  "maxItems": 150,&lt;br&gt;
  "sortBy": "date_listed"&lt;br&gt;
}&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;For Facebook Comments Scraper, feed it the specific post or listing URLs you want reactions on rather than trying to crawl comments broadly — Facebook's comment threading and pagination behave differently across post types, so targeted URL lists produce more reliable results than open-ended searches.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the output looks like
&lt;/h2&gt;

&lt;p&gt;The Marketplace row needs title, price, condition, location, seller name, listing date, and listing URL. Price and condition together are what make resale comps useful — a "good condition" $150 listing and a "like new" $150 listing aren't the same data point.&lt;/p&gt;

&lt;p&gt;The Comments row needs comment text, author, timestamp, and post URL, so a spike in complaints or praise can be traced back to the specific listing or post that triggered it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use case 1: resale price benchmarking
&lt;/h2&gt;

&lt;p&gt;A reseller flipping furniture, electronics, or collectibles can pull active listings for a category and location, then price new inventory against what's actually moving rather than guessing from a handful of manually checked listings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use case 2: gray-market and unauthorized resale monitoring
&lt;/h2&gt;

&lt;p&gt;A brand that sells through authorized retailers can search Marketplace for its product names to spot bulk resale, counterfeit listings, or unauthorized liquidation stock showing up at prices well below MSRP — a pattern that's hard to catch by browsing manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use case 3: local business post sentiment
&lt;/h2&gt;

&lt;p&gt;A local business posting about a new menu item, event, or policy change can pull comments on that post to see the actual reaction, rather than relying on the reaction-emoji count alone. Comment text often reveals &lt;em&gt;why&lt;/em&gt; a post landed well or badly, which the emoji summary doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production notes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scope by location first.&lt;/strong&gt; Marketplace is a local marketplace by design — location filtering matters more here than on almost any other platform in this batch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Track condition alongside price.&lt;/strong&gt; Two identical-looking listings at different prices are often explained by condition, not by one seller being irrational.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch listing velocity, not just price.&lt;/strong&gt; A category with fast-turning listings at a given price point tells you more about real demand than a handful of stale listings sitting unsold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Respect what "public" means here.&lt;/strong&gt; Only collect what's visible without logging in or joining a private group. Marketplace listings and public post comments qualify; private group content does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price without condition confuses more than it clarifies.&lt;/strong&gt; Price without condition and listing age produces comps that look contradictory. Adding condition and days-listed as required fields fixes most of the confusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;th&gt;Local granularity&lt;/th&gt;
&lt;th&gt;Sentiment data&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Manual browsing&lt;/td&gt;
&lt;td&gt;One category at a time&lt;/td&gt;
&lt;td&gt;High, but slow&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;General resale-price tools&lt;/td&gt;
&lt;td&gt;Limited or no Marketplace coverage&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apify Marketplace + Comments pipeline&lt;/td&gt;
&lt;td&gt;Category and location scoped&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Check the current Pricing tab on each Actor page before running at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Can Apify scrape Facebook Marketplace listings?
&lt;/h2&gt;

&lt;p&gt;Yes. Facebook Marketplace Scraper extracts public listing data including price, condition, and seller information.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I pull comments from any Facebook post?
&lt;/h2&gt;

&lt;p&gt;Facebook Comments Scraper works on public posts and videos; private group or profile content isn't accessible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this useful outside of reselling?
&lt;/h2&gt;

&lt;p&gt;Yes — brand protection, local sentiment tracking, and community research all use the same two-Actor pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the fastest way to try this?
&lt;/h2&gt;

&lt;p&gt;Pick one product category and city, run Facebook Marketplace Scraper for current listings, then run Facebook Comments Scraper on the most-commented listings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;Pick one product category and one city. Run &lt;a href="https://apify.com/crawlerbros/facebook-marketplace-scraper" rel="noopener noreferrer"&gt;Facebook Marketplace Scraper&lt;/a&gt; for current listings, then run &lt;a href="https://apify.com/crawlerbros/facebook-comments-scraper" rel="noopener noreferrer"&gt;Facebook Comments Scraper&lt;/a&gt; on the two or three most-commented listings you find. The price spread plus the comment tone will tell you more about that local market than either dataset alone.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>marketing</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
