<?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>Buy in Poland, sell in France: a 110% margin that nets €4.61</title>
      <dc:creator>Ahmed Jasarevic</dc:creator>
      <pubDate>Sun, 25 Oct 2026 12:35:01 +0000</pubDate>
      <link>https://dev.to/apify/buy-in-poland-sell-in-france-a-110-margin-that-nets-eu461-2a0p</link>
      <guid>https://dev.to/apify/buy-in-poland-sell-in-france-a-110-margin-that-nets-eu461-2a0p</guid>
      <description>&lt;h2&gt;
  
  
  The 110% Vinted arbitrage margin is really 9%
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;A four-market Vinted scan reported a 110.1% spread on one sneaker query. After five costs it was 9.2% — below the threshold I would act on, and one return eats two winners. Here is the arithmetic, the dataset, and the traps.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclosure: I built the Actor used in this post. The numbers are from a real run on 25 October 2026: 480 listings across PL, DE, IT and FR, ECB reference rates from 23 October. Everything below is recomputable from the dataset.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The number everyone screenshots
&lt;/h2&gt;

&lt;p&gt;A Vinted scan returns a report that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;================ CROSS-COUNTRY ARBITRAGE REPORT ================
1. PL  162.99 PLN  (avg 37.09 EUR / median 37.89 EUR)  range 12-300  n=120
2. DE  71.27 EUR   (avg 71.27 EUR / median 50 EUR)     range 5-300   n=120
3. IT  76.92 EUR   (avg 76.92 EUR / median 50 EUR)     range 5-300   n=120
4. FR  77.92 EUR   (avg 77.92 EUR / median 55 EUR)     range 5-300   n=120
===============================================================
BUY in  PL  37.09 EUR
SELL in FR  77.92 EUR
Gross margin: 110.1% (before fees, shipping and Vinted's buyer protection)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;110.1%. Buy for €37, sell for €78, more than double your money.&lt;/p&gt;

&lt;p&gt;The last line is the honest one, and it is the line everyone skips: &lt;em&gt;before fees, shipping and buyer protection&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This post is the rest of the calculation. Not to kill the idea, but because I would rather have ten trades that make 25% than a spreadsheet full of trades that make minus six.&lt;/p&gt;

&lt;h2&gt;
  
  
  First problem: the report compares averages, and the average is not the market
&lt;/h2&gt;

&lt;p&gt;Here is the same run at the median instead of the average.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Market&lt;/th&gt;
&lt;th&gt;Currency&lt;/th&gt;
&lt;th&gt;Average (EUR)&lt;/th&gt;
&lt;th&gt;Median (EUR)&lt;/th&gt;
&lt;th&gt;Range (EUR)&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PL&lt;/td&gt;
&lt;td&gt;PLN&lt;/td&gt;
&lt;td&gt;37.09&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;37.89&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;12 - 300&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DE&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;71.27&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;50.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5 - 300&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IT&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;76.92&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;50.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5 - 300&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FR&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;77.92&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;55.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5 - 300&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 110.1% comes from comparing the Polish average (37.09) to the French average (77.92). The medians tell a different story: 37.89 versus 55.00. That is a &lt;strong&gt;45.2% spread, not 110.1%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The gap between average and median is the shape of the distribution, not free money:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In PL the average sits &lt;strong&gt;below&lt;/strong&gt; the median (37.09 vs 37.89), meaning a tail of very cheap listings drags the mean down.&lt;/li&gt;
&lt;li&gt;In FR the average sits &lt;strong&gt;above&lt;/strong&gt; the median (77.92 vs 55.00), meaning a tail of expensive listings pulls the mean up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That €22.92 gap between the French mean and the French median represents &lt;strong&gt;€2,750 of value spread across 120 listings&lt;/strong&gt;. It is a distribution, not a data error, and you cannot buy a distribution. You buy one item at one price, and you sell it for one price.&lt;/p&gt;

&lt;p&gt;Notice something else in the table: DE and IT have identical medians (€50.00). The "third cheapest market" in the report is an artefact of the average, not a real price level. The report sorts markets by mean, which is exactly the statistic most distorted by outliers at both ends. Use it to pick candidates, never to size a trade.&lt;/p&gt;

&lt;p&gt;Rule one: compare medians first, then pull P25 and P75 from the dataset. If the spread only exists between the means, it does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five costs that eat the spread
&lt;/h2&gt;

&lt;p&gt;Here is the same trade with the costs that Vinted, shipping and reality add. Everything below is in EUR, using the medians above and assumptions you can edit.&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;Cost&lt;/th&gt;
&lt;th&gt;Who pays&lt;/th&gt;
&lt;th&gt;Size in this example&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;Vinted fee on the buy leg&lt;/td&gt;
&lt;td&gt;Buyer (you)&lt;/td&gt;
&lt;td&gt;~5% + fixed, so €2.59 on €37.89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Inbound shipping to you&lt;/td&gt;
&lt;td&gt;Buyer (you)&lt;/td&gt;
&lt;td&gt;€4.50 (EU parcel, tracked)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Currency conversion&lt;/td&gt;
&lt;td&gt;You&lt;/td&gt;
&lt;td&gt;~1.2% per converted leg, €1.11 total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Packaging + handling&lt;/td&gt;
&lt;td&gt;You&lt;/td&gt;
&lt;td&gt;€0.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Risk reserve&lt;/td&gt;
&lt;td&gt;You&lt;/td&gt;
&lt;td&gt;10% of buy price (returns, deadstock, price drift), €3.79&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few notes on each, because this is where most spreadsheets quietly cheat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The Vinted fee.&lt;/strong&gt; There are no selling fees on Vinted, which is why the fee is easy to forget: the &lt;em&gt;buyer&lt;/em&gt; pays it. When you source, you are the buyer. Vinted describes it as typically a percentage plus a fixed amount, roughly 5% + a small fixed fee, varying by market. On a €38 item it is not the main cost. On a €380 item it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Inbound shipping.&lt;/strong&gt; You pay to get the item to you. The buyer of your resale pays the label on the way out, so outbound shipping is not your cost, but packaging is. This asymmetry is why cheap, light items can work and bulky ones rarely do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. FX.&lt;/strong&gt; The ECB reference rate is a mid-market rate. Your payment provider's rate is not. Vinted documents a 1.2% conversion fee on the USD/GBP pairs it handles directly; for other pairs the fee depends on your provider. Your bank may add its own on top. Converting in and out means you pay it twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Packaging.&lt;/strong&gt; Small, real, and it compounds. A €0.50 mailer on a €38 trade is 1.3% of your capital, every cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Risk reserve.&lt;/strong&gt; This is the one people leave out because it does not appear on any invoice. Some items arrive not as described. Some do not sell for eight weeks while your money sits in them. Some sell after the market has moved down. A 10% reserve is not pessimism; it is the cost of doing this more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The arithmetic
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fee        = 37.89 * 0.05 + 0.70        =  2.59
fx         = (37.89 + 55.00) * 0.012    =  1.11
risk       = 37.89 * 0.10               =  3.79
packaging  =                               0.50
shipping   =                               4.50

all-in cost = 37.89 + 2.59 + 1.11 + 3.79 + 0.50 + 4.50 = 50.39
sell price  =                                            55.00
net         =                                             4.61
net margin  =                                             9.2%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade that showed a 110.1% gross margin nets &lt;strong&gt;€4.61, or 9.2%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is not a catastrophe. It is also not a business. Two numbers fall out of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Break-even sell price: €50.39.&lt;/strong&gt; You already need to clear the French median (€55.00) just to make €4.61. If the item sells at the median price of the &lt;em&gt;cheapest&lt;/em&gt; French listing band, you lose money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 25% net margin needs about €62.99.&lt;/strong&gt; In this run, that is above the median and somewhere in the upper quartile of the French market. If your "arbitrage" only exists at the median, you are buying a lottery ticket with extra steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then the return. One SNAD return — item not as described, refund, return shipping, packaging, your time — costs roughly €9 on a trade this size. That is two winners to pay for one loser. At a 10% return rate, the expected net per trade drops from €4.61 to about €3.25. At 20%, it is under €2. The margin is not just thin; it is one bad batch away from zero.&lt;/p&gt;

&lt;p&gt;Here is the same model as code, so you can drop your own numbers in:&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;FEE_PCT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&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;FEE_FIXED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.70&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;FX_LEG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.012&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;PACKAGING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.50&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;RISK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;netMargin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;buy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inboundShipping&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;fee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;FEE_PCT&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;FEE_FIXED&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;fx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buy&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;sell&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;FX_LEG&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;risk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;RISK&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;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buy&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;inboundShipping&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;fx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;PACKAGING&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;risk&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="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;net&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sell&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;netPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sell&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;breakEvenSell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sellFor25Pct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.25&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;netMargin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;buy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;37.89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;55.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;inboundShipping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.50&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="c1"&gt;// cost 50.39 | net 4.61 | netPct 0.092 | breakEven 50.39 | 25% target 62.99&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The real killer is not the fee. It is the border.
&lt;/h2&gt;

&lt;p&gt;The model above assumes you can buy in Poland and sell in France. That assumption is doing more work than any of the five costs.&lt;/p&gt;

&lt;p&gt;Your Vinted account is registered in one market. What you can buy and sell across borders depends on Vinted's international connections, the shipping options available to that pair of countries, item restrictions, weight limits and customs. Some connections are rolled out gradually and limited. On the connections Vinted documents publicly, electronics, cosmetics, jewellery and watches are commonly excluded or restricted, and those are exactly the categories with the widest spreads.&lt;/p&gt;

&lt;p&gt;So in practice, "sell in FR" often means: have a French account, a French delivery address, French shipping options, and the ability to handle returns in French. That is an operations problem, not a data problem. It is also why the gross-margin number should be treated as a &lt;em&gt;screening&lt;/em&gt; signal, not a business plan.&lt;/p&gt;

&lt;p&gt;There is a second border that is not geographic: DAC7. If you complete 30 sales or earn more than €2,000 on Vinted in a calendar year, the platform reports your seller data to tax authorities. That does not automatically mean tax is due, but it does mean the hobby has a paper trail. Once you are buying to resell, you are probably a business, and the EU margin scheme for second-hand goods is the thing to read before you scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually survives the model
&lt;/h2&gt;

&lt;p&gt;Running this across a few categories, four patterns hold up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Same-market mispricing beats cross-border arbitrage.&lt;/strong&gt; A mispriced listing in your own market has no customs, no FX, no cross-border shipping and no language problem. The scanner finds those too; you just have to stop filtering for a country flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High value-to-weight wins.&lt;/strong&gt; Sneakers, jackets, small designer accessories. A €200 item that weighs 800g absorbs the same €4.50 shipping that destroys a €38 item.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;P25 buy to P75 sell.&lt;/strong&gt; If the spread only exists median-to-median, the trade dies at the first cost. Require the sell-side target to sit above P75 before you even look at the item.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundles amortise everything.&lt;/strong&gt; One shipment, one fee, one packaging cost, several items. This is the only structural way to make low-priced categories work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the conclusion I did not expect: &lt;strong&gt;the data is worth more than the trades.&lt;/strong&gt; If you already sell on Vinted, the same scan tells you which market your item is priced highest in, and what the top quartile looks like. You do not have to ship anything across a border to profit from that. Price at P75 in the market with the highest median, and you have captured most of the spread without the operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five data traps to check before you trust a scan
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Asking prices are not sold prices.&lt;/strong&gt; Vinted does not publish a public sold-price feed. Everything you scan is a listing, including the ones that will never sell. This is the single biggest limitation of any Vinted price dataset, mine included. Treat medians as an upper bound on clearing prices, not a forecast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Averages hide the tails.&lt;/strong&gt; This run's headline 110.1% was mean-to-mean. The medians said 45.2%. Always recompute before you believe the report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicates across markets.&lt;/strong&gt; The same listing can surface in several markets. &lt;code&gt;itemId&lt;/code&gt; is stable; dedupe on it before you compute anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Condition mix.&lt;/strong&gt; A market with more "satisfactory" items will look cheaper. In this run the French range starts at €5, which is either a different product, a damaged item, or a placeholder price. Filter by condition (&lt;code&gt;status_ids&lt;/code&gt;) before comparing, or you are comparing two different products.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sample size.&lt;/strong&gt; n=120 is enough to see a shape and not enough to trust a percentile. If the difference between two markets is smaller than the spread of either, you have measured noise.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to run the scan
&lt;/h2&gt;

&lt;p&gt;The Actor takes &lt;code&gt;query&lt;/code&gt;, &lt;code&gt;countries&lt;/code&gt; (up to five per run), &lt;code&gt;maxItems&lt;/code&gt; per market, and optional filters. &lt;code&gt;CROSS_COUNTRY&lt;/code&gt; mode returns the listings plus the per-market report.&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;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CROSS_COUNTRY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Nike Jordan 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;"countries"&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;"pl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"de"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"it"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status_ids"&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;"new_with_tags"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"very_good"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"good"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"priceBandPartitioning"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter for data quality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Price bands.&lt;/strong&gt; Vinted caps how many results a single search pass returns. Setting &lt;code&gt;maxPrice&lt;/code&gt; enables &lt;code&gt;priceBandPartitioning&lt;/code&gt;, which splits the range into bands and sweeps each one, so a large query does not silently truncate at the cap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Five markets per run.&lt;/strong&gt; Run a second pass for the remaining countries and merge the datasets; &lt;code&gt;itemId&lt;/code&gt; is your join key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From code:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ApifyClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;apify-client&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;client&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;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;run&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;client&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ahmed_jasarevic/vinted-cross-country-arbitrage-scraper&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="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CROSS_COUNTRY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Nike Jordan 4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;countries&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;pl&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;de&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;fr&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;it&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;maxItems&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="na"&gt;minPrice&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="na"&gt;maxPrice&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="kd"&gt;const&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;listItems&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;items&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rows&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// quartiles per market. Replace RATES with the ECB reference rates for your run date.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;EUR&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;PLN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2275&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;GBP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;CZK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0405&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;SEK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0895&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;DKK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.134&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;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&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="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&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;b&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&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;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sorted&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;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&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;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lo&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="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lo&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lo&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;byMarket&lt;/span&gt; &lt;span class="o"&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;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;acc&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="o"&gt;=&amp;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;acc&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;country&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="nf"&gt;push&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;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RATES&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;currency&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;NaN&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;acc&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;for &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;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prices&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;byMarket&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;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&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="nb"&gt;isFinite&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;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clean&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;p25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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;median&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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;p75&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="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;At $1.50 per 1,000 results, 480 rows costs about $0.72. That is cheap enough to run weekly, which is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  A seven-day protocol
&lt;/h2&gt;

&lt;p&gt;Do not act on a single scan. Do this instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Day 1.&lt;/strong&gt; Baseline scan, fixed query, fixed filters, five markets. Save the raw dataset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 2-7.&lt;/strong&gt; Same input, once a day. Track the median per market, not the average.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 7.&lt;/strong&gt; Keep only pairs where the P75 sell side clears your all-in cost by 25%, and where the gap has held for at least three days. Gaps that appear and vanish in 24 hours are usually one seller, not a market.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then verify by hand.&lt;/strong&gt; Open the top five candidates, check condition, photos, size, seller feedback and whether the item is still live. The scanner gives you candidates; it does not give you judgement.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would not do
&lt;/h2&gt;

&lt;p&gt;I would not run this as a dropshipping business. The cross-border operational layer, the return risk and the sold-price blindness are all working against you, and no amount of scraping fixes any of them.&lt;/p&gt;

&lt;p&gt;I would run it as a pricing tool. It answers a question every reseller actually has: &lt;em&gt;what does this thing clear for, in which market, at the top of the range?&lt;/em&gt; That answer is worth more than a 110% screenshot.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The Actor used here is &lt;a href="https://apify.com/ahmed_jasarevic/vinted-cross-country-arbitrage-scraper" rel="noopener noreferrer"&gt;Vinted Cross-Country Arbitrage&lt;/a&gt; — 26 European markets, ECB-normalised prices, per-market medians and pairwise gross margins. The fee and FX assumptions above are documented in the post; change them before you make a decision.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you want the version of this analysis with your own category, run one scan and drop the numbers into &lt;code&gt;netMargin()&lt;/code&gt;. The result is usually more useful than the headline.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>ecommerce</category>
      <category>data</category>
      <category>api</category>
    </item>
    <item>
      <title>What AI agents see when they call your Apify Actors</title>
      <dc:creator>SIÁN Agency</dc:creator>
      <pubDate>Fri, 23 Oct 2026 07:09:09 +0000</pubDate>
      <link>https://dev.to/apify/what-ai-agents-see-when-they-call-your-apify-actors-1hhn</link>
      <guid>https://dev.to/apify/what-ai-agents-see-when-they-call-your-apify-actors-1hhn</guid>
      <description>&lt;p&gt;&lt;em&gt;This post first appeared on the &lt;a href="https://blog.apify.com/what-ai-agents-see-calling-actors/" rel="noopener noreferrer"&gt;Apify blog&lt;/a&gt; as part of &lt;a href="https://apify.com/resources/write-for-apify" rel="noopener noreferrer"&gt;Write for Apify&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run more than 300 Apify Actors at &lt;a href="https://www.sian-agency.online/" rel="noopener noreferrer"&gt;SIÁN&lt;/a&gt;, my data-pipeline and automation agency, mostly for e-commerce and property data, and I spend a lot of my week looking at run telemetry.&lt;/p&gt;

&lt;p&gt;In mid-July I was chasing a free-tier abuse problem. Someone had worked out that a free Apify account could call a monetized Actor over the API, grab the capped row allowance, abort before anything billed, and repeat. On July 14, 2026, I shipped a gate: block runs where the plan is free &lt;em&gt;and&lt;/em&gt; the origin is &lt;code&gt;API&lt;/code&gt;, before the Actor does any billable work. Console runs stayed free, because that's how people try an Actor.&lt;/p&gt;

&lt;p&gt;The next day I checked whether the gate had holes, and found one. &lt;code&gt;MCP&lt;/code&gt; is its own Apify &lt;code&gt;RunOrigin&lt;/code&gt;, separate from &lt;code&gt;API&lt;/code&gt;. It's what you get when someone wires the &lt;a href="https://docs.apify.com/platform/integrations/mcp" rel="noopener noreferrer"&gt;Apify MCP server&lt;/a&gt; into Claude, Cursor, or their own agent and the model calls your Actor as a tool. My gate didn't cover it.&lt;/p&gt;

&lt;p&gt;So I extended it: block when the plan is free and the origin is either &lt;code&gt;API&lt;/code&gt; or &lt;code&gt;MCP&lt;/code&gt;. I shipped that across five Actors, feeling reasonably good about closing a hole before it cost me anything.&lt;/p&gt;

&lt;p&gt;I had the story completely backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the telemetry actually said
&lt;/h2&gt;

&lt;p&gt;Every run in my fleet writes a row to a Postgres table: origin, tier, outcome, rows, revenue. I finally aggregated the origin column across all 169,941 rows, which I should have done before writing the gate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Origin&lt;/th&gt;
&lt;th&gt;Runs&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;&lt;code&gt;API&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;156,482&lt;/td&gt;
&lt;td&gt;92.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;MCP&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6,922&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WEB&lt;/code&gt; (Console)&lt;/td&gt;
&lt;td&gt;5,183&lt;/td&gt;
&lt;td&gt;3.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SCHEDULER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;853&lt;/td&gt;
&lt;td&gt;0.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLI&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;501&lt;/td&gt;
&lt;td&gt;0.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My first MCP run landed on June 17, 2026. Six weeks later it was 1,407 runs a week and still climbing, and it had quietly passed Apify Console to become my second-biggest source of runs.&lt;/p&gt;

&lt;p&gt;Then the part that made me wince. Of those 6,922 MCP runs, exactly 44 were rejected by my abuse gate. That's 0.6%. The rest were 86.9% successful, half of them from paying accounts, and delivered 67,323 rows.&lt;/p&gt;

&lt;p&gt;I had spent a day hardening a channel that was, by a wide margin, my healthiest one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents are better callers than humans
&lt;/h2&gt;

&lt;p&gt;Once I stopped treating MCP as a threat and started treating it as a user, I compared it with the other origins:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Origin&lt;/th&gt;
&lt;th&gt;Success rate&lt;/th&gt;
&lt;th&gt;Malformed input&lt;/th&gt;
&lt;th&gt;Share paid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MCP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.6%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;50.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;API&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;88.0%&lt;/td&gt;
&lt;td&gt;1.4%&lt;/td&gt;
&lt;td&gt;80.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WEB&lt;/code&gt; (Console)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Humans fail more than half their Console runs. Agents fail 13.1% of theirs, and send malformed input: 1.6% versus 8.8%.&lt;/p&gt;

&lt;p&gt;That's not because models are careful. It's because the MCP server hands a model my input schema as JSON Schema, and the model sends exactly what that schema declares. A human in Console gets a rendered form: labeled fields, a dropdown of valid enum values, an example sitting in the box, and a validator that won't let them submit nonsense. None of that lives in the schema. It's scaffolding Console builds &lt;em&gt;around&lt;/em&gt; the schema.&lt;/p&gt;

&lt;p&gt;Take the scaffolding away and whatever my schema literally says becomes the entire contract. So I went looking for where my schemas were lying.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first approach was wrong too
&lt;/h2&gt;

&lt;p&gt;I did the obvious thing and grepped all 253 schemas the fleet had in July for the failure mode I already knew about: a field with a &lt;code&gt;prefill&lt;/code&gt; (which only populates Console's editor) but no &lt;code&gt;default&lt;/code&gt; (which the platform actually injects into every run). A model sending &lt;code&gt;{}&lt;/code&gt; gets the default and never sees the prefill.&lt;/p&gt;

&lt;p&gt;The grep found 74 Actors. I had a bug list, or so I thought.&lt;/p&gt;

&lt;p&gt;I've been burned by static analysis enough times to check before acting on it, so I wrote a probe that calls an Actor through the MCP server with &lt;code&gt;{}&lt;/code&gt;, precisely what a model sends when told nothing is required. I ran it against the 16 most-used of them.&lt;/p&gt;

&lt;p&gt;Roughly half were fine:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Actors&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;th&gt;What the agent sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;REJECTED&lt;/code&gt; (-32602)&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;56.2%&lt;/td&gt;
&lt;td&gt;actionable protocol error, no run billed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOUD-FAIL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;12.5%&lt;/td&gt;
&lt;td&gt;run failed, visibly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SILENT-EMPTY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;18.8%&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SUCCEEDED&lt;/code&gt;, zero rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SILENT-STATS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;12.5%&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SUCCEEDED&lt;/code&gt;, rows that aren't data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The grep had flagged Actors that behave correctly, and it could never have found the category that actually matters. &lt;code&gt;SILENT-EMPTY&lt;/code&gt; and &lt;code&gt;SILENT-STATS&lt;/code&gt; are the same defect wearing different clothes. Five of these 16 calls failed in a way no agent can detect, and no amount of schema-reading surfaces that. You have to run the thing.&lt;/p&gt;

&lt;p&gt;Getting those five counted correctly took two passes, because my first probe was also wrong. The MCP call returns after a wait cap of about 30 seconds, so any Actor slower than that comes back still &lt;code&gt;RUNNING&lt;/code&gt;, and I was scoring that as a failure. Three property scrapers got marked broken when they were only slow. One of them was genuinely broken in the quiet way, and my bug had filed it under the loud way, which flattered the result.&lt;/p&gt;

&lt;p&gt;The fix is to poll until the run settles, and to give "still running" a verdict of its own so it can never be mistaken for a failure:&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;TERMINAL&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;SUCCEEDED&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;FAILED&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;ABORTED&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;TIMED-OUT&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;status&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;status&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;TERMINAL&lt;/span&gt;&lt;span class="p"&gt;:&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-STILL-RUNNING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;     &lt;span class="c1"&gt;# never call a slow Actor a broken one
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Actor knew exactly what was wrong and told no one
&lt;/h2&gt;

&lt;p&gt;Here's &lt;a href="https://apify.com/sian.agency/apartments-com-property-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;apartments-com-property-scraper&lt;/code&gt;&lt;/a&gt;, one of mine, called with &lt;code&gt;{}&lt;/code&gt;. This is the complete set of signals a model receives.&lt;/p&gt;

&lt;p&gt;Run metadata:&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SUCCEEDED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"runTimeSecs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;3.846&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 MCP result flag:&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;"isError"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the summary the MCP server writes for the model, verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SUCCEEDED in 3.846s. 1 item; 32 fields available. Key-value store has 2 keys. Use get-dataset-items with datasetId=… and limit (for example 20) to fetch items (1 total). Available fields (dot notation): &lt;code&gt;__retryHelper&lt;/code&gt;, &lt;code&gt;searchMode&lt;/code&gt;, &lt;code&gt;mode&lt;/code&gt;, &lt;code&gt;queriesRequested&lt;/code&gt;, …&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every one of those says the call worked and produced a result. So the agent fetches the item:&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;"__retryHelper"&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;"searchMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"byLocation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"queriesRequested"&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;"queriesSucceeded"&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;"listingsPushed"&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;"totalResultsAvailable"&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;"userTier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PAID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"failures"&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;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"(input)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"location must be a non-empty string."&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;There it is: &lt;code&gt;location must be a non-empty string.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;My Actor diagnosed the problem correctly, phrased it in plain English, and filed it inside a nested array, on a row that isn't a result, on a run marked &lt;code&gt;SUCCEEDED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An agent that asked for apartments now holds one object with 32 fields, none of which is an apartment. Best case it tells its user "no listings found." Worst case it tries to summarize a run-summary row as a property listing.&lt;/p&gt;

&lt;p&gt;Nobody forgot to handle the error. The only thing missing was the idea that something other than a human reading a run log would ever need to find it. I built that Actor when the only caller I imagined was a person with a browser open. That field carries a default now, which is why the probe returns &lt;code&gt;OK&lt;/code&gt; against it today.&lt;/p&gt;

&lt;h2&gt;
  
  
  A platform bug I found on the way
&lt;/h2&gt;

&lt;p&gt;While isolating why the MCP server rejected some Actors cleanly and not others, I found a mismatch in the server itself.&lt;/p&gt;

&lt;p&gt;Ask the MCP server for a tool definition and compare it against the Actor's own schema:&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Mcp&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sian.agency/costco-data-scraper&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&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="mi"&gt;0&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;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;inputSchema&lt;/span&gt;&lt;span class="sh"&gt;"&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;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;     &lt;span class="c1"&gt;# []
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the relevant part of that Actor's &lt;code&gt;.actor/INPUT_SCHEMA.json&lt;/code&gt;:&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;"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;"operation"&lt;/span&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;"operation"&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;"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;"default"&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"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enum"&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;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"productDetails"&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;The Actor declares &lt;code&gt;operation&lt;/code&gt; required. The tool definition handed to the model says nothing is required. So the model calls with &lt;code&gt;{}&lt;/code&gt;, and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MCP error -32602: Invalid arguments for tool "sian-dot-agency--costco-data-scraper".
Validation errors: root: must have required property 'operation'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rejected for missing exactly the field it was told it could omit.&lt;/p&gt;

&lt;p&gt;The discriminator is the &lt;code&gt;default&lt;/code&gt;. &lt;code&gt;tools/list&lt;/code&gt; drops a field from &lt;code&gt;required&lt;/code&gt; when that field carries a default. That's defensible on its own: a defaulted field is satisfiable without the caller. But &lt;code&gt;tools/call&lt;/code&gt; validates against the raw schema and doesn't apply defaults first. The two halves disagree.&lt;/p&gt;

&lt;p&gt;The control case confirms it. &lt;a href="https://apify.com/sian.agency/amazon-product-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;amazon-product-scraper&lt;/code&gt;&lt;/a&gt; also declares a required field, &lt;code&gt;urls&lt;/code&gt;, but &lt;code&gt;urls&lt;/code&gt; has a &lt;code&gt;prefill&lt;/code&gt; and no &lt;code&gt;default&lt;/code&gt;, so nothing gets stripped:&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;&lt;code&gt;INPUT_SCHEMA&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Field has &lt;code&gt;default&lt;/code&gt;?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;costco-data-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['operation']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ebay-data-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['operation']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bayut-property-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['purpose']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;glassdoor-data-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['operation']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;booking-com-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['operation']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;amazon-product-scraper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['urls']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;['urls']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across the 253 schemas, 59 Actors declare a required field that also carries a default, or 23.3%. Every one tells a model a mandatory field is optional.&lt;/p&gt;

&lt;p&gt;In fairness, it's recoverable. The error is clear, and a competent agent retries or calls &lt;code&gt;fetch-actor-details&lt;/code&gt;, which reports &lt;code&gt;required&lt;/code&gt; correctly. My &lt;a href="https://apify.com/sian.agency/aliexpress-product-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;aliexpress-product-scraper&lt;/code&gt;&lt;/a&gt; is in the affected set and is still my second-most agent-called Actor at 818 MCP runs, 95.0% of them paid. Models cope, but they burn a round-trip on the first call, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Apify's QA can't catch this
&lt;/h2&gt;

&lt;p&gt;Apify runs every public Actor daily and requires &lt;code&gt;SUCCEEDED&lt;/code&gt; plus a dataset with rows in it within five minutes. Three consecutive failures flag it "under maintenance." It's a good gate and it has caught real breakage for me.&lt;/p&gt;

&lt;p&gt;It runs the Actor with its &lt;em&gt;prefill&lt;/em&gt; input.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;SILENT-EMPTY&lt;/code&gt; or &lt;code&gt;SILENT-STATS&lt;/code&gt; run reports &lt;code&gt;SUCCEEDED&lt;/code&gt;, so the gate sees what the agent sees and agrees with it. And the prefill is only one point in my input space. Every other enum value is untested by construction.&lt;/p&gt;

&lt;p&gt;My &lt;a href="https://apify.com/sian.agency/suumo-property-scraper" rel="noopener noreferrer"&gt;&lt;code&gt;suumo-property-scraper&lt;/code&gt;&lt;/a&gt; hardcoded a region parameter while advertising all 47 Japanese prefectures. About 40 of them returned zero results, silently, from launch. It never tripped the gate, because the one prefecture the prefill uses happens to sit in the hardcoded region. A human clicking through the dropdown would have found it eventually. An agent picking a prefecture at random finds it immediately, and gets &lt;code&gt;SUCCEEDED&lt;/code&gt; and an empty dataset.&lt;/p&gt;

&lt;p&gt;The prefill is the path humans take. Agents explore everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Give every load-bearing field a &lt;code&gt;default&lt;/code&gt;, not just a &lt;code&gt;prefill&lt;/code&gt;.&lt;/strong&gt; An audit of 16 of my freshly built Actors found five with a prefill and no default. Fine in Console, broken over the API. My build gate now fails any field with one and not the other.&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;"location"&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;"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;"prefill"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Austin, TX"&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="s2"&gt;"Austin, TX"&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;My most agent-called Actor, &lt;a href="https://apify.com/sian.agency/instagram-ai-transcript-extractor" rel="noopener noreferrer"&gt;&lt;code&gt;instagram-ai-transcript-extractor&lt;/code&gt;&lt;/a&gt; at 2,496 MCP runs, works with &lt;code&gt;{}&lt;/code&gt; for this reason and no other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never return silence.&lt;/strong&gt; If you can't produce results, say so in a row:&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;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="na"&gt;status&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_results&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;location must be a non-empty string&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;Push it unbilled, and mark your dataset schema fields &lt;code&gt;nullable: true&lt;/code&gt; so it survives validation. An empty dataset isn't a message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't gate inference on a field the platform always injects.&lt;/strong&gt; Apify writes schema defaults into every run's input, so this never fires:&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchMode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;infer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// dead code on the platform&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I shipped an Actor whose "auto-detected" mode did exactly this, and a customer who pasted listing URLs got billed for default discovery results instead. Let the specific input win:&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;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listingUrls&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;byListingUrl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test the whole enum space.&lt;/strong&gt; One loop over every declared enum value would have caught the 40 dead prefectures on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat scraped text as untrusted input.&lt;/strong&gt; What you return now lands in a model's context, upstream of its next decision. Keep scraped content in clearly typed fields rather than free-text blobs that read like prose, and treat only your own harness's framing as authoritative, never instruction-shaped text that arrived inside a tool result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it against your own Actors
&lt;/h2&gt;

&lt;p&gt;The probe is on GitHub at &lt;a href="https://github.com/rara-cyber/apify-agent-call-probe" rel="noopener noreferrer"&gt;apify-agent-call-probe&lt;/a&gt;. It needs an Apify account, an API token in &lt;code&gt;APIFY_TOKEN&lt;/code&gt;, and Python 3.9 or newer: the MCP client is about 60 lines of standard library, with no dependencies.&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;export &lt;/span&gt;&lt;span class="nv"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_token_here
python3 agent_call_probe.py sian.agency/apartments-com-property-scraper
&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;SILENT-STATS  status=SUCCEEDED isError=False items=1
Actor knew what was wrong but never surfaced it:
   failures[0].error: location must be a non-empty string.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;REJECTED&lt;/code&gt; and &lt;code&gt;OK&lt;/code&gt; are fine. &lt;code&gt;LOUD-FAIL&lt;/code&gt; is survivable. &lt;code&gt;SILENT-EMPTY&lt;/code&gt; and &lt;code&gt;SILENT-STATS&lt;/code&gt; mean an agent calling that Actor today is reporting your failure to its user as a fact about the world.&lt;/p&gt;

&lt;p&gt;Runs cost 0.0001 to 0.005 compute units each, because the failing paths exit early, so sweeping an account costs roughly nothing.&lt;/p&gt;

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

&lt;p&gt;I'd have aggregated the origin column before writing the gate. The whole detour started because I saw a new origin in a security context and pattern-matched it to a threat, when four minutes of SQL would have told me it was my healthiest channel. The instinct to treat unfamiliar traffic as hostile is usually right and was expensive here.&lt;/p&gt;

&lt;p&gt;And I'd stop treating &lt;code&gt;INPUT_SCHEMA.json&lt;/code&gt; as configuration for a form. It's a published API contract now, and machines read it far more often than people do. Everything in this article follows from not having noticed when that flipped.&lt;/p&gt;

&lt;p&gt;If you run public Actors, the two-minute version is: point the probe at your three most-used ones. I expected mine to be fine. Nearly a third of them weren't.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All figures come from SIÁN's own run telemetry and from live calls against the Apify MCP server on July 31, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>webscraping</category>
      <category>testing</category>
    </item>
    <item>
      <title>MCP + CLI &gt; MCP vs. CLI</title>
      <dc:creator>Jan Čurn</dc:creator>
      <pubDate>Wed, 14 Oct 2026 12:06:02 +0000</pubDate>
      <link>https://dev.to/apify/mcp-cli-mcp-vs-cli-36hd</link>
      <guid>https://dev.to/apify/mcp-cli-mcp-vs-cli-36hd</guid>
      <description>&lt;p&gt;&lt;a href="https://x.com/skirano/status/2034269154404868314" rel="noopener noreferrer"&gt;That tweet&lt;/a&gt; got 1.6k likes in March: &lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2034269154404868314-418" src="https://platform.twitter.com/embed/Tweet.html?id=2034269154404868314"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2034269154404868314-418');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2034269154404868314&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;p&gt;Perplexity’s CTO &lt;a href="https://x.com/morganlinton/status/2031795683897077965" rel="noopener noreferrer"&gt;reportedly told his team&lt;/a&gt; they’re moving from MCP to APIs and CLIs:&lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2031795683897077965-871" src="https://platform.twitter.com/embed/Tweet.html?id=2031795683897077965"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2031795683897077965-871');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2031795683897077965&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;p&gt;OpenClaw &lt;a href="https://x.com/chrysb/status/2025701861031121331" rel="noopener noreferrer"&gt;left MCP out on purpose&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2025701861031121331-772" src="https://platform.twitter.com/embed/Tweet.html?id=2025701861031121331"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2025701861031121331-772');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2025701861031121331&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;p&gt;Garry Tan’s version was shorter: &lt;a href="https://x.com/garrytan/status/2031910564344262988" rel="noopener noreferrer"&gt;“MCP sucks honestly.”&lt;/a&gt;"&lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2031910564344262988-310" src="https://platform.twitter.com/embed/Tweet.html?id=2031910564344262988"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2031910564344262988-310');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2031910564344262988&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They’re half right: the problem is real, but the diagnosis isn’t.&lt;/strong&gt; What’s broken is how most agents use MCP, and the fix is not to throw the protocol away but to stop using it badly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This piece builds on my talk at AI Engineer World’s Fair “MCP doesn’t suck. Your agent does.” (San Francisco, July 2026). &lt;a href="https://www.youtube.com/watch?v=pAnLpiAG6Es" rel="noopener noreferrer"&gt;Watch it on YouTube&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/pAnLpiAG6Es" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem is the harness, not the protocol
&lt;/h2&gt;

&lt;p&gt;Most agents treat MCP tools as prompt-time function calls. Connect a server and every tool definition lands in the context window. Call a tool and the full result lands there too. Ten servers with five tools each, and you pay thousands of tokens on every turn before the agent has done anything. Results pile up, context rots, the agent gets slower and less accurate, and data the model never needed to see is now inside the model.&lt;/p&gt;

&lt;p&gt;None of that is in the spec. MCP already has the fixes: progressive tool discovery (Anthropic’s &lt;a href="https://www.anthropic.com/engineering/advanced-tool-use" rel="noopener noreferrer"&gt;tool search tool&lt;/a&gt;, Cursor’s &lt;a href="https://cursor.com/blog/dynamic-context-discovery" rel="noopener noreferrer"&gt;dynamic context discovery&lt;/a&gt;), code mode (&lt;a href="https://blog.cloudflare.com/code-mode/" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt; and &lt;a href="https://www.anthropic.com/engineering/code-execution-with-mcp" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt; both wrote it up), and sub-agents with split context. But most MCP clients don’t implement them. The protocol moves faster than the ecosystem.&lt;/p&gt;

&lt;p&gt;Now look at what CLIs get right, mostly by accident. Agents never load a CLI’s docs all at once; they read &lt;code&gt;--help&lt;/code&gt; when they need it. They run CLI tools as code by default, chaining them with pipes and shell scripts. And every model knows the shell by heart, because there’s 50 years of it in the training data. The catch here: a CLI is a local black box. No standard transport, no standard auth, no discovery, no way to scope or intercept what it does to a remote service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That’s why agents have MCP connectors, not CLI connectors.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP for remote, CLI for local
&lt;/h2&gt;

&lt;p&gt;The fix is to use each where it’s strong. MCP stays the standard remote interface: server discovery, authentication, and access control. The CLI becomes the local interface between the agent and MCP. That’s what we built in &lt;a href="https://github.com/apify/mcpc" rel="noopener noreferrer"&gt;&lt;code&gt;mcpc&lt;/code&gt;&lt;/a&gt;, an open-source universal CLI client for MCP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ┌──────────┐   Bash()   ┌────────┐    MCP    ┌────────────┐
 │ AI agent │ ─────────► │  mcpc  │ ────────► │ MCP server │
 └──────────┘            └────────┘           └────────────┘
                                     Sessions, OAuth, Tools,
                                     Resources, Prompts,
                                     Tasks, x402, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The agent only needs one tool, &lt;code&gt;Bash()&lt;/code&gt;, to call &lt;code&gt;mcpc&lt;/code&gt;, learn how it works through &lt;code&gt;--help&lt;/code&gt;, and interact with MCP servers. And it can leverage the full MCP protocol out of the box: persistent sessions, OAuth 2.1 (including enterprise SSO), tools, resources, prompts, async tasks, skills, x402 payments, and a code mode using &lt;code&gt;--json&lt;/code&gt;. &lt;code&gt;mcpc&lt;/code&gt; speaks MCP 2026-07-28 and falls back to older versions. No LLM inside, fast, and minimal dependencies. As a bonus, one login and one set of live sessions can be shared between many agents on the same machine.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it looks like in the shell
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;apify/tap/mcpc          &lt;span class="c"&gt;# or: npm install -g @apify/mcpc&lt;/span&gt;

mcpc login mcp.apify.com             &lt;span class="c"&gt;# OAuth once; tokens go to the OS keychain&lt;/span&gt;
mcpc connect mcp.apify.com @apify    &lt;span class="c"&gt;# persistent session&lt;/span&gt;
mcpc @apify                          &lt;span class="c"&gt;# server info, capabilities, instructions&lt;/span&gt;
mcpc @apify &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"crawler"&lt;/span&gt;           &lt;span class="c"&gt;# progressive tool discovery&lt;/span&gt;
mcpc @apify tools-get search-actors  &lt;span class="c"&gt;# one tool's schema, only when needed&lt;/span&gt;
mcpc @apify tools-call search-actors keywords:&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"tiktok scraper"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And code mode, where the agent writes a script instead of making a round trip through its context for every call:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mcpc @apify tools-call search-actors keywords:&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"tiktok scraper"&lt;/span&gt; limit:&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.content[0].text | capture("`(?&amp;lt;actor&amp;gt;[^`]+)`").actor'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | xargs &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; mcpc @apify tools-call fetch-actor-details actor:&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"{}"&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That’s progressive discovery and code mode on any MCP server, whether or not the agent’s own MCP client supports either.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the evals say
&lt;/h2&gt;

&lt;p&gt;We built &lt;a href="https://github.com/apify-projects/connector-bench" rel="noopener noreferrer"&gt;connector-bench&lt;/a&gt; to measure the efficiency of &lt;code&gt;mcpc&lt;/code&gt; against CLIs and naive MCP implementations. Each run gives an agent the same cross-server task under three harnesses: raw MCP with every tool loaded into context, the native CLIs for the same services, and &lt;code&gt;mcpc&lt;/code&gt;. We track cumulative cost against wall-clock time.&lt;/p&gt;

&lt;p&gt;Here’s an example comparing &lt;a href="https://mcp.apify.com/" rel="noopener noreferrer"&gt;Apify MCP server&lt;/a&gt; with &lt;a href="https://docs.apify.com/cli/" rel="noopener noreferrer"&gt;Apify CLI&lt;/a&gt; on an Actor discovery scenario:&lt;/p&gt;



&lt;p&gt;In many of our tests, &lt;code&gt;mcpc&lt;/code&gt; finishes the job faster than native CLI, albeit sometimes with higher cost. That’s understandable, as native optimized CLIs are usually less verbose and more token-efficient than a generic CLI wrapper for MCP. But we believe there is plenty of room for optimization and the performance of &lt;code&gt;mcpc&lt;/code&gt; will converge towards native CLIs.&lt;/p&gt;
&lt;h2&gt;
  
  
  So stop saying CLI beats MCP
&lt;/h2&gt;

&lt;p&gt;MCP + CLI beats both. Even OpenClaw uses MCP through &lt;a href="https://github.com/openclaw/mcporter" rel="noopener noreferrer"&gt;mcporter&lt;/a&gt;, an MCP-to-CLI converter, which proves the point. The problem was never the protocol. It was that agents implemented MCP poorly.&lt;/p&gt;

&lt;p&gt;MCP has ten official SDKs for languages like TypeScript, Python, or Go, but none for the shell, the most universal programming language. &lt;code&gt;mcpc&lt;/code&gt; is our attempt to fill that gap, so any agent with a code runtime can use all of MCP.&lt;/p&gt;

&lt;p&gt;Try it, break it, star it: &lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/apify" rel="noopener noreferrer"&gt;
        apify
      &lt;/a&gt; / &lt;a href="https://github.com/apify/mcpc" rel="noopener noreferrer"&gt;
        mcpc
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      The most compatible MCP CLI client. Supports: tools, resources, prompts, completions, async tasks, skills, stdio and Streamable HTTP (MCP 2026-07-28 and 2025-11-25). Persistent sessions, OAuth 2.1 with DCR, CIMD, client credentials, and ID-JAG, OS keychain, --json for code mode, grep across servers, proxy for AI sandboxes, x402 &amp;amp; more.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;mcpc — a universal MCP CLI client&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/0a88327748a227ae8bf40048cee3a8233ea9ed2a6d688d39d0b92d7ff556c286/68747470733a2f2f61706966792e6769746875622e696f2f6d6370632f636c69656e742d6c6f676f2e7376673f763d33"&gt;&lt;img src="https://camo.githubusercontent.com/0a88327748a227ae8bf40048cee3a8233ea9ed2a6d688d39d0b92d7ff556c286/68747470733a2f2f61706966792e6769746875622e696f2f6d6370632f636c69656e742d6c6f676f2e7376673f763d33" alt="mcpc logo"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.npmjs.com/package/@apify/mcpc" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/2753d4659235a3bf7754e65d01939c21790adfcd4db4ba581c6f16b7cb79235e/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f762f4061706966792f6d6370632e737667" alt="npm version"&gt;&lt;/a&gt;
&lt;a href="https://www.npmjs.com/package/@apify/mcpc" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/557f903ac4b9fd0a69a6c7cfeefabe0c0d51981b99a71d3d12afe95b9207e019/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f646d2f4061706966792f6d6370632e737667" alt="npm downloads"&gt;&lt;/a&gt;
&lt;a href="https://github.com/apify/mcpc/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/apify/mcpc/actions/workflows/ci.yml/badge.svg" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://github.com/apify/mcpc/blob/main/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/f405a432f040b3a418d080841f2039f194b87a6469a6c4a1e6f1d269715cd043/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f6c2f4061706966792f6d6370632e737667" alt="License"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;mcpc&lt;/code&gt; is a command-line client for the &lt;a href="https://modelcontextprotocol.io/" rel="nofollow noopener noreferrer"&gt;Model Context Protocol (MCP)&lt;/a&gt;
that maps every MCP operation to an intuitive shell command.&lt;/p&gt;
&lt;p&gt;Use it to manually inspect and debug MCP servers, to script repeatable MCP workflows in plain shell, or to
give AI agents the full MCP protocol through a single &lt;code&gt;Bash()&lt;/code&gt; tool call, so they can interact with any MCP
server and its latest capabilities using the most universal programming interface there is: the UNIX shell.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key features:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;🔧 &lt;a href="https://github.com/apify/mcpc#mcp-support" rel="noopener noreferrer"&gt;&lt;strong&gt;Full MCP support&lt;/strong&gt;&lt;/a&gt; - Tools, prompts, resources, async tasks, skills, notifications, and logging over stdio and Streamable HTTP.&lt;/li&gt;
&lt;li&gt;🔄 &lt;strong&gt;Persistent sessions&lt;/strong&gt; - Keep connections to multiple servers alive in parallel, whether the server protocol is stateful or stateless.&lt;/li&gt;
&lt;li&gt;🗺️ &lt;strong&gt;Progressive tool discovery&lt;/strong&gt; - Find relevant MCP tools on the fly to save tokens and increase accuracy.&lt;/li&gt;
&lt;li&gt;🔌 &lt;strong&gt;Code mode&lt;/strong&gt; - JSON output composes with…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/apify/mcpc" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>mcp</category>
      <category>ai</category>
      <category>cli</category>
      <category>aie</category>
    </item>
    <item>
      <title>Your KYB agent reads "Active" and moves on. Five register answers that are true and still wrong</title>
      <dc:creator>getregdata</dc:creator>
      <pubDate>Tue, 13 Oct 2026 14:36:28 +0000</pubDate>
      <link>https://dev.to/apify/your-kyb-agent-reads-active-and-moves-on-five-register-answers-that-are-true-and-still-wrong-108c</link>
      <guid>https://dev.to/apify/your-kyb-agent-reads-active-and-moves-on-five-register-answers-that-are-true-and-still-wrong-108c</guid>
      <description>&lt;p&gt;On 4 December 2020 the Belgian Official Gazette published a list of 1,663 companies struck off the Crossroads Bank for Enterprises (KBO) on 24 November 2020 for not filing annual accounts for three years in a row. On 25 September 2026 we looked up 100 of them, spaced evenly through the list.&lt;/p&gt;

&lt;p&gt;78 were still struck off. 30 of those 78 still showed Status "Active" on their KBO page, and 12 of the 30 also showed Legal situation "Normal situation".&lt;/p&gt;

&lt;p&gt;Ask an agent "is this Belgian company active?" and hand it that page, and it answers yes. The answer is correct, since the register says Active. It is also the wrong thing to act on if you are about to extend credit to a company that has filed no balance sheet since 2020.&lt;/p&gt;

&lt;p&gt;This post collects five results like that. All five come from registers we read every day for KYB, credit and insolvency checks, and several of them came out of our own tools before we fixed them. The runs succeeded, the rows parsed, and the result was not empty. The trouble is in what the data means, and a model has no way to know that from the JSON alone.&lt;/p&gt;

&lt;p&gt;Other posts in this organization have covered empty-versus-failed results and timeouts in agent tool calls. This one is about results that arrive complete and still mislead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the check belongs
&lt;/h2&gt;

&lt;p&gt;Each trap below gets a check, and each check is a few lines of deterministic code that runs between the tool and the model. That placement matters. You can write "remember that Belgian companies can be struck off while Active" into a system prompt, but the agent has to recall it at the right moment, on the right field, for the right country. A function that runs on every record doesn't have to remember anything.&lt;/p&gt;

&lt;p&gt;So the pattern is: fetch, check, then give the model a decision and the reasons for it instead of the raw status. The fetching part is ordinary Apify client code:&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;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&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="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;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;list_items&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checks don't care which tool produced the rows. Field names below are the ones our actors emit. If you read these registers some other way, map them to your own schema; the logic carries over.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. "Active" is one field, and a strike-off lives in another
&lt;/h2&gt;

&lt;p&gt;The Belgian Code of Economic Law (art. III.42 § 1, 4°) lets the KBO strike a company off ex officio when National Bank data show it has not filed annual accounts for at least three consecutive financial years. A strike-off is not a dissolution: dissolving a company for missing accounts takes a court (art. 2:74 of the Companies and Associations Code). So the Status field doesn't change. The strike-off sits on its own line on the company page, "Ex officio striking off", with a since-date, and it is lifted once the missing accounts are filed.&lt;/p&gt;

&lt;p&gt;Our own Belgian actor didn't read that line until build 1.0.7 in September 2026. Before then it returned exactly what an onboarding check wants to see, a valid enterprise number with status Active, for companies that had been struck off for years. It now returns &lt;code&gt;struckOff&lt;/code&gt; and a &lt;code&gt;strikeOffs&lt;/code&gt; list with the reason, a type and the since-date. A company can carry two strike-offs at once, one for accounts and one for beneficial-ownership filings.&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;def&lt;/span&gt; &lt;span class="nf"&gt;check_kbo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;reasons&lt;/span&gt; &lt;span class="o"&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;struck off ex officio since &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&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;sinceDate&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;s&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;reason&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="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rec&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;strikeOffs&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&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;hold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rec&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;status&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Active&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hold&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status is &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rec&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;status&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pass&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule behind it applies well beyond Belgium: a status field summarises one dimension of a company's legal life, and the register may record other dimensions somewhere else. Before you trust a status field, find out what it does not cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One row is a record, not a company
&lt;/h2&gt;

&lt;p&gt;Poland's insolvency and enforcement register (KRZ) lists proceedings, and a company can be party to many of them. Until build 1.1.19 our KRZ actor's company search returned one row per participant record, with the case fields empty. For Getin Noble Bank that meant one row, an enforcement case that had ended. The register holds six proceedings for it, one of them an ongoing bankruptcy.&lt;/p&gt;

&lt;p&gt;An agent reading "1 result, ended" concludes the company once had a minor enforcement matter that is now closed. That is the opposite of the truth, and nothing in the row looks broken.&lt;/p&gt;

&lt;p&gt;The actor now emits one row per proceeding, puts open proceedings first, and marks &lt;code&gt;proceedingsComplete: false&lt;/code&gt; on any company whose full list it could not retrieve. Your check should still aggregate before deciding, because an agent that reads only the first row, or a no-code integration that does, makes the same mistake one level up.&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;def&lt;/span&gt; &lt;span class="nf"&gt;check_krz&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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;proceedingsComplete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;):&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&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;proceeding list incomplete for at least one record&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;open_cases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
        &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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;proceedingStatus&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w toku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# "in progress"
&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;r&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;proceedingStatus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;r&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;proceedingEndDate&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;open_cases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;kinds&lt;/span&gt; &lt;span class="o"&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;r&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;proceedingKind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;open_cases&lt;/span&gt;&lt;span class="p"&gt;})&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;hold&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="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;open_cases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; open proceeding(s): &lt;/span&gt;&lt;span class="si"&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kinds&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;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pass&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="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;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; proceeding(s), all closed&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;When you build a register check, write down the grain of a row (a filing, a participant, a proceeding, an announcement) before you write any logic that counts rows or reads the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The first 100 rows are a sample, and a biased one
&lt;/h2&gt;

&lt;p&gt;Spain's insolvency register (Registro Público Concursal) records three roles per person or company: debtor, disqualified, and insolvency administrator. For a director check, "disqualified" is the role that matters.&lt;/p&gt;

&lt;p&gt;On 31 August 2026 we searched it for two common surnames. Martínez matched 9,587 entries and Fernández 8,889. In both cases the first 100 rows were all debtors, with zero disqualified people and zero administrators. In rows 101 to 250 the other roles turned up: 6 disqualified and 27 administrators for Martínez, 7 and 29 for Fernández. The first non-debtor row came at position 118 and 158.&lt;/p&gt;

&lt;p&gt;The register lists debtors first. Our actor's default &lt;code&gt;maxResults&lt;/code&gt; is 100. Put those together and a director check on a common name returns 100 rows, every one of them &lt;code&gt;isDisqualified: false&lt;/code&gt;. That looks like a thorough negative, and it is really a sample of the one role that happens to come first.&lt;/p&gt;

&lt;p&gt;We made the same mistake ourselves before we caught it. We first concluded that the disqualified flag was never populated, because 294 of 294 stored rows had it false. Every one of those rows came from a run capped at or near 100. What we had measured was the cap.&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;MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_es&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="n"&gt;max_results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regdata/spain-concursal-scraper&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;searchMode&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;byName&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;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;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;maxResults&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;max_results&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_es_roles&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;max_results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MAX&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;max_results&lt;/span&gt; &lt;span class="ow"&gt;and&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;rows&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;max_results&lt;/span&gt;&lt;span class="p"&gt;:&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&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result hit the cap of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;max_results&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;; this is the head of a sorted list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&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;isDisqualified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hold&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="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;hits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; disqualified match(es)&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;hits&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pass&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the check returns &lt;code&gt;unknown&lt;/code&gt;, re-run with &lt;code&gt;maxResults: 0&lt;/code&gt; (no cap), or narrow the query with an identity number. Any capped list behaves like this. Unless you know the sort order, the head of the list tells you nothing about the rest of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A zero is a claim, and it needs evidence of the right size
&lt;/h2&gt;

&lt;p&gt;The same Spanish register once gave our actor a false all-clear on every large name search. For the most common surnames it took about ten seconds to answer, longer than the actor waited. The actor read the empty results table as "0 matches" and stamped the result "verified against a control query".&lt;/p&gt;

&lt;p&gt;The control query was real. It searched for a known insolvent company, and it passed every time. But that company has about a hundred entries, and the register answered for it in under three seconds. A query of that size only shows that small queries come back. It said nothing about a query with ten thousand matches.&lt;/p&gt;

&lt;p&gt;Since build 1.0.14 the actor bases the verdict on the register's answer arriving, not on time elapsed. Any search the register did not answer is written to an &lt;code&gt;UNVERIFIED&lt;/code&gt; record in the run's key-value store, and the status message says outright that it is not a clean result. Your side of the check is short:&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;def&lt;/span&gt; &lt;span class="nf"&gt;check_zero&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUCCEEDED&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&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="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;unverified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;key_value_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultKeyValueStoreId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;get_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UNVERIFIED&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;unverified&lt;/span&gt;&lt;span class="p"&gt;:&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&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;register did not answer some searches; do not read as clean&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;rows&lt;/span&gt;&lt;span class="p"&gt;:&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;pass&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;register answered: no entries&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;   &lt;span class="c1"&gt;# rows present: hand over to the role check above
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The general rule is about your own monitoring as much as anyone's tool. A canary or control query vouches only for queries of its own size and shape. If your pipeline's real queries are larger, slower or rarer than the canary, they need a positive control of their own, meaning a query you know should return a lot of results, run on the same schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A clean number can come from the wrong row
&lt;/h2&gt;

&lt;p&gt;Polish companies file financial statements with the court register, and one of the formats they come in is a rendered document, not structured data. In July 2026 our KRS financials actor read CD Projekt's FY2025 balance sheet and returned short-term liabilities of 279,811,608,000 and equity of 1,010, flagged &lt;code&gt;success: true&lt;/code&gt;. Both values were well-formed numbers, and nothing about their format was wrong. Two adjacent table cells had been merged into one number, and the reporting period's date range had been read as a figure.&lt;/p&gt;

&lt;p&gt;Format checks can't catch this, but the balance sheet itself can. Assets have to equal equity plus liabilities, and the asset subtotals have to add up to total assets. After the fix, CD Projekt's figures reconcile exactly, using values taken from two different tables: fixed assets 2,174,743 plus current assets 1,332,673 equals total 3,507,416, and equity 3,294,816 plus long-term 29,408 plus short-term 183,192 equals the same 3,507,416.&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;def&lt;/span&gt; &lt;span class="nf"&gt;check_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tol&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="n"&gt;need&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;totalAssets&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;fixedAssets&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;currentAssets&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;equity&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;longTermLiabilities&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;shortTermLiabilities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;need&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&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="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&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;if&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&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&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cannot verify, missing &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;missing&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;errs&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="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fixedAssets&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;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currentAssets&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;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;totalAssets&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="n"&gt;tol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fixed + current != total assets&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="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;equity&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;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;longTermLiabilities&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;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shortTermLiabilities&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;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;totalAssets&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="n"&gt;tol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;equity + liabilities != total assets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errs&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;errs&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pass&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These two identities fit the summary balance sheet this filing format carries. If your schema splits out provisions or accruals, add them to the right-hand side. Keep the tolerance small, at rounding level.&lt;/p&gt;

&lt;p&gt;The harder part is the income statement, which has no internal identity to test against. A wrong revenue figure passes every format check and nothing flags it. So after the balance-sheet fix our actor kept returning revenue, gross profit and net profit for that filing as null, on purpose, for two months. They came back in September only after we found a second table in the same filing that carries the same figures. A profit-and-loss figure is now published only when both tables agree. If a figure has no arithmetic cross-check, use a second source or return null. An agent will use whatever number it receives.&lt;/p&gt;

&lt;h2&gt;
  
  
  A smaller one: two kinds of null
&lt;/h2&gt;

&lt;p&gt;One more, briefly, because it changes how you triage. Germany's Handelsregister output carries a legal-form code and a legal-form label. Until build 1.1.10 our actor had labels for only two of the codes, so SE, KG, UG and every other form came back with &lt;code&gt;legalFormCode&lt;/code&gt; set and &lt;code&gt;legalForm: null&lt;/code&gt;. A code with no label means a lookup gap in the tool. When both fields are null, the source document was not retrieved at all, and the run says so with a partial status. These two nulls call for different responses (report a bug, or re-run), and an agent told to treat null as "unknown" will handle both the same way. Check the sibling fields before you decide what a null means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into an agent
&lt;/h2&gt;

&lt;p&gt;Put the checks together and the agent no longer receives a register row. It receives something 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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;screen_be_company&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enterprise_number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regdata/belgium-kbo-company-scraper&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;searchMode&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;byNumber&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;enterpriseNumbers&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;enterprise_number&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;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUCCEEDED&lt;/span&gt;&lt;span class="sh"&gt;"&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;rows&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&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;unknown&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;reasons&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;no verified record&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;source&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;KBO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check_kbo&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="mi"&gt;0&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasons&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&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;KBO&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;runId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;run&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three design choices are deliberate here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;unknown&lt;/code&gt; is a separate decision. It is not a soft &lt;code&gt;pass&lt;/code&gt;. A capped list, an unanswered search and an incomplete proceeding list all land there, and your workflow decides what &lt;code&gt;unknown&lt;/code&gt; means, which is usually re-run or send to a human.&lt;/li&gt;
&lt;li&gt;The reasons are plain sentences. The model can quote "struck off ex officio since 2020-11-24 for non-filing of annual accounts" to a user. It can't do much with &lt;code&gt;struckOff: true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;runId&lt;/code&gt; stays with the decision. When someone asks six months later why a supplier was put on hold, you can show the run that answered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model still does what it is good at. It explains the decision, drafts the email asking the supplier for its filed accounts, and works out what to do next. It just doesn't get to decide what "Active" means.&lt;/p&gt;

&lt;p&gt;If you want to try the first check, the Belgian actor is &lt;a href="https://apify.com/regdata/belgium-kbo-company-scraper?fpr=getregdata" rel="noopener noreferrer"&gt;on Apify&lt;/a&gt;. The checks themselves are plain Python, and they work just as well on data you collect yourself.&lt;/p&gt;

&lt;p&gt;Which register field has misled you? Put it in the comments. We would like to add to this list, especially for registers we don't read.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>webscraping</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Top 10 Real Estate Portals in 2026 — and What Data You Can Actually Extract</title>
      <dc:creator>Ahmed Jasarevic</dc:creator>
      <pubDate>Tue, 13 Oct 2026 09:29:34 +0000</pubDate>
      <link>https://dev.to/apify/top-10-real-estate-portals-in-2026-and-what-data-you-can-actually-extract-3j04</link>
      <guid>https://dev.to/apify/top-10-real-estate-portals-in-2026-and-what-data-you-can-actually-extract-3j04</guid>
      <description>&lt;p&gt;Zillow gets &lt;strong&gt;247 million visits a month&lt;/strong&gt;. Rightmove effectively &lt;em&gt;is&lt;/em&gt; the UK house-price index. Idealista is where price-per-m² in Madrid, Milan and Lisbon gets checked.&lt;/p&gt;

&lt;p&gt;Treat them as what they are: the biggest structured property databases on the planet — updated daily, no official public API.&lt;/p&gt;

&lt;p&gt;Here are 10 of the largest property portals, what each one actually exposes, and how to get it out without writing a scraper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The list (ranked by monthly traffic, Aug 2026)
&lt;/h2&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;Portal&lt;/th&gt;
&lt;th&gt;Market&lt;/th&gt;
&lt;th&gt;Visits/mo&lt;/th&gt;
&lt;th&gt;Standout data&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;Zillow&lt;/td&gt;
&lt;td&gt;USA&lt;/td&gt;
&lt;td&gt;247M&lt;/td&gt;
&lt;td&gt;Zestimate + rent estimates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Redfin&lt;/td&gt;
&lt;td&gt;USA&lt;/td&gt;
&lt;td&gt;64M&lt;/td&gt;
&lt;td&gt;Price history, walk/transit scores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Rightmove&lt;/td&gt;
&lt;td&gt;UK&lt;/td&gt;
&lt;td&gt;57M&lt;/td&gt;
&lt;td&gt;Sold prices, tenure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Idealista&lt;/td&gt;
&lt;td&gt;ES/IT/PT&lt;/td&gt;
&lt;td&gt;51M&lt;/td&gt;
&lt;td&gt;Price/m², energy rating&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Apartments.com&lt;/td&gt;
&lt;td&gt;USA&lt;/td&gt;
&lt;td&gt;47M&lt;/td&gt;
&lt;td&gt;Rent ranges, leasing phones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;ImmobilienScout24&lt;/td&gt;
&lt;td&gt;Germany&lt;/td&gt;
&lt;td&gt;39M&lt;/td&gt;
&lt;td&gt;Energy class, construction year&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Homes.com&lt;/td&gt;
&lt;td&gt;USA&lt;/td&gt;
&lt;td&gt;34M&lt;/td&gt;
&lt;td&gt;Agent name + phone per listing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Funda&lt;/td&gt;
&lt;td&gt;Netherlands&lt;/td&gt;
&lt;td&gt;25M&lt;/td&gt;
&lt;td&gt;Neighbourhood hierarchy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Trulia&lt;/td&gt;
&lt;td&gt;USA&lt;/td&gt;
&lt;td&gt;21M&lt;/td&gt;
&lt;td&gt;Neighborhood context, price history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Bayut&lt;/td&gt;
&lt;td&gt;UAE/Gulf&lt;/td&gt;
&lt;td&gt;Gulf #1&lt;/td&gt;
&lt;td&gt;Off-plan payment plans, AED prices&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;General classifieds (Craigslist, Leboncoin) excluded — they're not dedicated property portals. Bayut is ranked by regional leadership, not global traffic.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get per portal
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Zillow&lt;/strong&gt; — price, address components, beds/baths, living + lot area, Zestimate and rent estimates, lat/lng, property type, status, photos, stable property ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redfin&lt;/strong&gt; — price &lt;em&gt;and&lt;/em&gt; price history, AVM (Redfin Estimate) history, sqft, MLS IDs, days-on-market, HOA, walk/transit/bike scores, agent + broker metadata.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rightmove&lt;/strong&gt; — price/currency, beds/baths, property type + &lt;strong&gt;tenure&lt;/strong&gt;, coordinates, price-change history, branch/estate-agent info, Land Registry-backed sold prices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idealista&lt;/strong&gt; — price, &lt;strong&gt;price/m²&lt;/strong&gt;, area, rooms, floor, GPS, &lt;strong&gt;energy rating&lt;/strong&gt;, year built, fees/taxes, price history, agency contact info.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apartments.com&lt;/strong&gt; — property name, street/city/state/ZIP, rent ranges, beds/baths, sqft, &lt;strong&gt;leasing-office phone&lt;/strong&gt;, URL, GPS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ImmobilienScout24&lt;/strong&gt; — price + type (rent/sale), rooms, m², address, &lt;strong&gt;energy efficiency class&lt;/strong&gt;, construction year, agent contacts, amenities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Homes.com&lt;/strong&gt; — address + geocoordinates, price, beds/baths, sqft, status, descriptions, &lt;strong&gt;listing agent name, phone and title&lt;/strong&gt;, brokerage, open-house/sold flags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Funda&lt;/strong&gt; — street/postcode/city/province/neighbourhood/municipality, numeric + formatted price, size, status, buy/rent, agent contacts, coordinates, floor plans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trulia&lt;/strong&gt; — price, beds/baths, interior + lot area, address + neighborhood, coordinates, agent/broker name + phone, per-property price history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bayut&lt;/strong&gt; — AED asking prices, coordinates, beds/baths, amenities, &lt;strong&gt;off-plan payment-plan milestones&lt;/strong&gt;, unit breakdowns, developer + agency profiles, completion status (UAE, KSA, Qatar, Jordan, Oman).&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema overlap (why merging portals is easy)
&lt;/h2&gt;

&lt;p&gt;Every one of them resolves to roughly the same record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;bedrooms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;bathrooms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;property&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;agent&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;branch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;photos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;scraped&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;at&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Portal-specific extras are additive: Zestimate (Zillow), walk scores (Redfin), tenure (Rightmove), €/m² + EPC (Idealista), payment plans (Bayut). Merge three portals into one table and you lose nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to pull the data without writing a scraper
&lt;/h2&gt;

&lt;p&gt;Each portal above has a maintained &lt;a href="https://apify.com/ahmed_jasarevic" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; Actor that queries the portal's &lt;strong&gt;internal JSON endpoints&lt;/strong&gt; — not rendered HTML — so output is structured on arrival:&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;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;425000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GBP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Queens Head Street, London, N1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bedrooms"&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;"bathrooms"&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;"propertyType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Apartment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;51.5366&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&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.0958&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agentName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Agency"&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://www.rightmove.co.uk/properties/173742266"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scrapedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-13T09:00:00Z"&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;Workflow: &lt;strong&gt;1)&lt;/strong&gt; set a search (city, ZIP, filtered URL) → &lt;strong&gt;2)&lt;/strong&gt; run → &lt;strong&gt;3)&lt;/strong&gt; export JSON/CSV/Excel → &lt;strong&gt;4)&lt;/strong&gt; schedule it (daily for price-drop monitoring, weekly for market snapshots).&lt;/p&gt;

&lt;p&gt;You can also drive it from code — the same Actors are callable over the Apify REST API with a single &lt;code&gt;POST /v2/acts/{id}/runs&lt;/code&gt;, so a cron job + webhook gives you a property data feed.&lt;/p&gt;

&lt;p&gt;Actor links: &lt;a href="https://apify.com/ahmed_jasarevic/zillow-scraper" rel="noopener noreferrer"&gt;Zillow&lt;/a&gt; · &lt;a href="https://apify.com/ahmed_jasarevic/redfin-scraper" rel="noopener noreferrer"&gt;Redfin&lt;/a&gt; · &lt;a href="https://apify.com/ahmed_jasarevic/rightmove-scraper" rel="noopener noreferrer"&gt;Rightmove&lt;/a&gt; · &lt;a href="https://apify.com/ahmed_jasarevic/idealista-property-scraper" rel="noopener noreferrer"&gt;Idealista&lt;/a&gt; · &lt;a href="https://apify.com/ahmed_jasarevic/apartments-scraper" rel="noopener noreferrer"&gt;Apartments.com&lt;/a&gt; · &lt;a href="https://apify.com/ahmed_jasarevic/immobilienscout24-scraper-pro" rel="noopener noreferrer"&gt;ImmobilienScout24&lt;/a&gt; · &lt;a href="https://apify.com/ahmed_jasarevic/homes-scraper" rel="noopener noreferrer"&gt;Homes.com&lt;/a&gt; · &lt;a href="https://apify.com/ahmed_jasarevic/funda-scraper" rel="noopener noreferrer"&gt;Funda&lt;/a&gt; · &lt;a href="https://apify.com/ahmed_jasarevic/trulia-scraper" rel="noopener noreferrer"&gt;Trulia&lt;/a&gt; · &lt;a href="https://apify.com/ahmed_jasarevic/bayut-scraper" rel="noopener noreferrer"&gt;Bayut&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is there a real estate API alternative to Zillow?&lt;/strong&gt;&lt;br&gt;
Zillow's official API is closed to new sign-ups and Rightmove never had a public one. Scraper-as-API is the practical alternative: pay per result, no data contract, same fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is scraping property portals legal?&lt;/strong&gt;&lt;br&gt;
Listings are public pages. The risks live elsewhere: bypassing access controls, republishing photos/text, and outreach under GDPR/CCPA/CAN-SPAM. Respect each portal's ToS and robots.txt — this isn't legal advice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the best data source for property prices?&lt;/strong&gt;&lt;br&gt;
Idealista for €/m² (ES/IT/PT), Rightmove sold prices (UK), Funda (NL), ImmobilienScout24 (DE), Zillow/Redfin (US). Schedule recurring runs — trends need a time dimension.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I monitor new listings and price drops automatically?&lt;/strong&gt;&lt;br&gt;
Yes — schedule the Actor (daily/weekly) or call it from the API and attach a webhook. Results land in your DB, Sheet or CRM.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full breakdown with per-portal analysis: &lt;a href="https://medium.com/@jasarevicahmed999/the-10-biggest-property-portals-in-the-world-and-how-to-pull-clean-data-from-each-bd2e18adeacf?source=friends_link&amp;amp;sk=938bf4e9cfe6535a7d26709971802251" rel="noopener noreferrer"&gt;The 10 Biggest Property Portals in the World&lt;/a&gt; (Medium). I build the scrapers linked above — &lt;a href="https://apify.com/ahmed_jasarevic" rel="noopener noreferrer"&gt;230+ actors on Apify&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>dataengineering</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <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 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>
  </channel>
</rss>
