Dev.to WebDev 🛠 Dev 👁 0 📖 23 min read

Three correct answers to one question about one property

What four US county property systems taught me about building an Apify Actor an AI agent can trust. The Cook County assessor mailed a value of 3,041 for parcel 17052170040000. The assessor certified 3,041. The Board of

Three correct answers to one question about one property

What four US county property systems taught me about building an Apify Actor an AI agent can trust.

The Cook County assessor mailed a value of 3,041 for parcel 17052170040000. The assessor certified 3,041. The Board of Review cut it to 1,303.

Same parcel, same year, three numbers, 57% apart, and all three are correct. Which one answers "what is this assessed at" depends on where in the appeal cycle you were standing when you asked, and nothing in the number says which one you have.

There is a second trap in the same field. Illinois assesses residential property at a fraction of market value, roughly 10%, so 3,041 is not a $3,041 property and 1,303 is not a $1,303 one. The number is a step in a tax calculation, never a price.

I asked the same question seventeen hundred miles away. Parcel 3532803300, a house on Ridgegate Row in La Jolla, came back at 2,022,509.

That is a Proposition 13 factored base year value: what the property was worth when it last changed hands, grown by at most 2% a year since. For a house that last sold in the 1970s, that is a 1970s price compounded at 2%, which is not what a buyer would pay now and was never meant to be. Also not a market value. Also not comparable to the Illinois number in any direction.

Hand both numbers to an AI agent and ask which property is worth more. It will answer. It has no reason not to.

I built an Apify Actor that answers one question, "tell me about this parcel", across four US counties that publish that answer four completely different ways, and hands it to an agent through the Apify MCP (Model Context Protocol) Server. It parses no HTML anywhere, because there is no web page in the loop for any of the four: three publish APIs and the fourth publishes a 531 MB file. It is a router and a normalizer, which is a less exciting description than scraper, and a more accurate one.

Four counties is not a coverage claim, and I want that out of the way early. The US Census Bureau's 2024 estimates list 3,144 counties and county equivalents. Mine are four of them: 10.9 million people, 3.2% of the country, 0.13% of its counties. I picked them because each one forces a different access mode.

The caller is not a person

Every design decision below comes from one fact: the thing reading the answer is an agent, and an agent is not a person with a browser tab open.

A person asks about one county at a time. They read the answer sitting next to the query they typed, in a page that says which county they are looking at, and when something is missing they notice, because they were looking for it. Most of the context lives outside the response and it is free.

An agent asking four counties about one property gets back a flat list. Nothing in its situation supplies what the response leaves out. If a record does not say which county answered, then for that agent nothing did.

Three rules follow, and the rest of this article is mostly them being tested.

No bare numbers. Every amount in the output carries what it is a value of, and where in the process it was taken. Both, because they are two different axes: Florida publishes six figures for one parcel that differ by what is being valued (just value, land value, assessed school, assessed non-school, taxable school, taxable non-school), while Cook's three differ by when you asked, with the thing being valued held constant. A record that labels amounts by basis alone holds Florida and loses Cook. One that labels by stage alone does the reverse. So both ride on the amount itself, and there is no field anywhere in the record that an adapter could put an unlabeled number into.

Uncertainty is machine readable first. Anything the caller has to know to avoid being wrong is a flag from a fixed vocabulary, and only then a sentence.

A refusal comes back in the shape of a success. An agent can act on a structured refusal and can only give up on an error.

The idea that a record should explain itself is not new, and I am not going to pretend it is. What is worth writing about is enforcement. The whole of it is five lines in the record builder:

const unique = [...new Set(flags)].sort();
const unknown = unique.filter((f) => !(f in FLAG_NOTES));
if (unknown.length) throw new Error(`unknown flag(s): ${unknown.join(', ')}`);

const record = {
    /* the rest of the record */
    flags: unique,
    notes: unique.map((f) => FLAG_NOTES[f]),
};

FLAG_NOTES is the vocabulary, one note per flag, and it is the only place a note exists. A flag outside it throws while the record is being built, so a typo cannot ship as a warning that is silently absent, and the sentences are generated from the flags rather than written next to them, so the two cannot drift apart. The full file is src/record.js in the repository.

Four counties, four access modes

I picked the four counties so that no two of them could be reached the same way.

Cook, IL San Diego, CA Duval, FL Travis, TX
how it publishes Socrata open data portal public ArcGIS REST statewide program, all 67 counties bulk export, no API
one parcel costs 6 requests, issued in parallel 1 request 1 request, parcel id only 0 requests, local index
measured 0.9 to 1.3 s 0.7 to 1.4 s 0.4 to 15 s about 35 ms, off a local disk
freshness, August 2026 one day old, serves 2025 no signal of any kind serves 2025 serves 2026

The request count is the honest column. The three live figures are from one datacenter in Japan on 2026-08-13, five rounds each: Duval alone swung from 0.4 to 14.9 seconds inside that minute. A latency without a vantage point is a bare number.

Cook has the best open data program of the four and still needs six requests to describe one parcel. Location, values, owner and sales are four datasets joined on the fourteen-digit parcel number, which Illinois calls a PIN, and building characteristics are in a fifth. Issued serially those six requests take five seconds, or two if one connection is reused; issued in parallel they take about one second, and that difference decides whether Cook fits inside an agent's patience at all.

Travis inverts the entire table. The appraisal district publishes no endpoint that answers a question about one parcel, only the whole certified roll as a 531 MB archive, so the Actor ingests it once and answers from an index. Lookups come back in about 35 milliseconds, because nobody is being asked anything.

It is also the only one of the four that cannot be refreshed on demand, and in August it was the only one carrying the freshest assessment year. Travis certified its 2026 roll on 2026-07-18 and posted it four days later. Florida's statewide layer served assessment year 2025 when I probed it on 2026-08-02 and was still serving 2025 when I checked again on 2026-08-05.

So the fastest source is the only one that cannot be refreshed, and the county with no API had fresher data than the state program with the nice REST endpoint. Speed and currency are not the same axis, and an agent that reads a fast answer as a fresh one is wrong here specifically.

By November that order had changed: Florida's layer had moved to 2026, and so had Cook's values. The ranking is a measurement with a date on it, and without the date it is a bare number too.

That is why the router has to hide two things rather than one. Schema differences, obviously. But latency classes too, and it must not hide freshness, which is why as_of.basis is a controlled vocabulary with four values and one of them is unknown. San Diego's layer exposes editingInfo: null, and no date field in it says when the data was last updated. Unknown is the honest answer there, and it is worse than stale, because stale can at least be flagged as stale.

Wiring it into an agent

The Apify MCP Server exposes Actors to agents as tools. You need three things before this works: an Apify account, your own Actor deployed to it and an MCP-capable client. In Claude Code that is one command:

claude mcp add --transport http apify "https://mcp.apify.com?tools=actors,<username>/<actor-name>"

The ?tools= parameter is the part worth copying. It replaces the default tool set rather than extending it. Without it the server also loads documentation search and a web browsing Actor, which is context the agent pays for on every single turn. With it, the session gets the actors category and my own Actor, and nothing else.

One thing that cost me time, and it is in no error message. The client reads its configuration at startup, so adding a server does not affect the session that added it. Nothing reports this. The tools are simply not there, and it looks like the URL is wrong.

The open question I had going in was whether a private Actor is exposed as its own tool or only reachable through a generic call-by-name. It gets its own tool, carrying the full input schema: enums, defaults and the prefill example. I had kept the generic call-actor in the URL as insurance and did not need it for that.

The consequence took me a moment to appreciate. The input schema descriptions arrive in the tool definition, which means they are read by the model at the moment it chooses arguments. They are documentation for the agent, not decoration for the console. This sentence, from the jurisdiction field, is doing real work:

There is no auto-detection: resolving a county from an address is a separate problem, and guessing it wrong returns a confident answer about the wrong property.

It is a validation rule that cannot be validated, so it is written as a prompt instead, and it sits exactly where the wrong guess would otherwise happen. The input schema is a prompt as much as it is a schema.

A prompt with a budget, it turns out. In August, descriptions over 500 characters were cut mid-word; now they are cut at the last full sentence and marked [Description truncated], with the same 500 limit. I measured the number before I found it in the server's README, because nothing in the deploy points there. Two of my five were over, and the one offering a choice about publishing a person's name lost every word about what the choice does. Whatever changes a decision goes first now, and a test fails if it does not.

One caveat found the hard way, on the day I shipped a change. The tool definition my connected session held was a snapshot. I added an input field, redeployed, and the already-connected session still advertised the old four fields. Nothing errored. From the agent's side the field did not exist, so it kept calling the old shape and getting valid answers computed under the old default. The generic call-actor, the insurance I thought I had wasted, is what made the new field reachable before a reconnect.

The output side had none of that care. With no declared schema the platform tells an agent what an Actor returns by inferring it from recent runs, and mine came back as flags/0, flags/1, flags/2. Three flag fields, because that run had three flags. Declaring a dataset schema replaces the guess with the vocabulary in the dataset's metadata, though fetch-actor-details still infers, and writing one cost a failed run and found three places my own normalizer had drifted. That is worth knowing before you declare one: the platform validates every item on push and kills the Actor, which neither test suite could catch, because pushing is the only place a declaration and a record meet.

Apify console run list, Origin column reading MCP on the agent-driven runs and CLI on one earlier run.

The console's Origin column reading MCP for the agent-driven runs, and CLI for the one deploy check I ran by hand.

What came back, and what came back instead

Here is the Cook parcel this article opened with, as the agent received it.

{
  "valuation": {
    "amounts": [
      { "basis": "il_fractional_assessed", "stage": "mailed",          "amount": 3041 },
      { "basis": "il_fractional_assessed", "stage": "certified",       "amount": 3041 },
      { "basis": "il_fractional_assessed", "stage": "board_of_review", "amount": 1303 }
    ],
    "headline_basis": "il_fractional_assessed",
    "headline_stage": "board_of_review"
  },
  "flags": ["parcel_not_current", "sale_price_nominal", "values_under_appeal"]
}

Nine amounts in the real record, three shown. headline_stage names which single figure to quote when only one can be shown, and values_under_appeal exists because an agent reading board_of_review otherwise has no way to know the other two stages say something else.

Then the flag I did not expect to be writing about. parcel_not_current: this parcel stopped existing after 2016. Cook's historical datasets answer for it anyway, with an owner and values, and only the current-year universe knows it is gone. Three consecutive runs returned the same confident decade-old answer.

Which is the opening problem again, one turn deeper. Every figure in that record is correct. None of them is an answer about a property that exists, and correct is not the same as being the answer to the question that was asked.

The second artifact is a partial answer that says how partial it is. Asking Duval for 1431 Riverplace Blvd, Jacksonville returns five records, each carrying:

{
  "result_set": { "matched": 241, "returned": 5, "envelope_degrees": 0.002 },
  "flags": ["address_resolved_to_building", "resolved_via_external_geocoder", "results_truncated"]
}

The address is a condominium tower and Florida publishes one centroid per parcel, so it is 241 parcels, one per unit. "241 matched, you are seeing 5" and "one of the sections failed to load" are different facts that need different reactions from the caller, which is why they are different flags rather than one partial_result.

The third artifact is a refusal, and refusals are the reason this contract exists. Travis, asked for a parcel on the platform, where no index ships:

{
  "result": "index_not_built",
  "jurisdiction": { "county": "Travis", "state": "TX", "fips": "48453" },
  "requested": { "lookup_by": "parcel_id", "query": "100008" },
  "source": { "mode": "bulk_index", "endpoints": [] },
  "reason": "This county publishes no endpoint that answers a question about one parcel, so answers come from an index built from the certified export. No index has been built yet, and the platform build ships none.",
  "remedy": "Clone the repository and run bin/build-travis-index.js. It fetches 129 MB of a 531 MB archive and takes under a minute."
}

That is not an exception and not an error string. It names who refused, what was asked, what was contacted (nobody, and the empty array says so rather than the field being absent), why, and what to do about it. An agent can act on remedy. It can only give up on a stack trace.

One distinction matters here: genuinely empty is not a refusal. A parcel id that does not exist in the county comes back as an empty list, because that is a true answer. Duval's address_not_geocoded is a refusal because the trip failed rather than the address being absent, and the record carries how far the search went, so the caller can tell "there is no such parcel" from "I could not look".

That distinction is only worth anything on parcels I did not choose. Mine were four, picked while writing the adapters, so after publishing I sampled out of each county's own list instead. The first 250 broke something: headline_stage, the label naming which figure to quote, said board of review on Cook parcels whose year carried only the mailed values, so 99 of 250 named an amount that was not there. The parcel this article opens with has all three, which is why four of my own choosing could not have shown it. Fixed, and the next 10,000 broke none of the nine checks. Then I fed 200 of their addresses back in, and one Duval road address answered with the unit inside the building, one record and nothing to say so: the guard watches the filter, not the set.

JSON record with result_set showing matched 241 and returned 5, three flags including address_resolved_to_building, and the three notes those flags generated.

Five records returned, 241 matched, and the flags that say why.

What did not work first

Every rule above was bought with a mistake. Here they are in the order of how much each one changed the design.

I believed an error message five times

I rewrote the same filter five times, believing the message, before I accepted that my parameters had never been the problem. Filtering the statewide layer on an address returns HTTP 200 with {"error":{"code":400,"message":"Cannot perform query. Invalid query parameters."}} in the body, for a query whose parameters are demonstrably valid. It is deterministic: seven measurements across two dates fall between 55.5 and 58.5 seconds, with a 4.7 second control on the same connection. The real cause is an internal scan timeout across 10.8 million rows, reported as a client error.

Which is where it stops being a curiosity and starts being a design constraint. Asking the same filter for identifiers only returns all 398,063 parcels of one county in four seconds, and the same trick does nothing for an address: the service will spend 56 seconds failing to count the rows matching one street. So the response shape was a red herring. One predicate column is indexed, the other is not, and nothing published says which. The error body arrived in three different shapes across three days, which rules out matching on the message string as well.

The rule: success means the body carries the shape I asked for. Status codes and message strings are commentary. And a deterministic failure gets a different call shape, because an agent that retries this one spends three minutes of its own budget to hear the same thing.

The empty array that looked like my own mistake

The first thing I checked was my own filter, because an empty array is what a wrong filter looks like too. Summing the assessed total over the layer returns HTTP 200, the output field properly declared, and "features": []. The sum overflows the 32-bit integer the statistic is typed as. Binary search puts the boundary exactly: the first 1,793 parcels sum to 2,147,271,276 and answer, parcel 1,794 adds 627,300, and the row vanishes against a ceiling of 2,147,483,647. Nothing in that response is malformed. response.ok is true, there is no error key, the fields are populated, and a well-formed empty array is exactly what "no matching records" looks like. It took a control to convince me otherwise: count and max over the same 1,089,648 rows both answer in under two seconds, so the query was fine and the arithmetic was not.

What it changed: the classifier grew a fifth bucket, empty that should not be. The Actor now decides per call whether empty is a possible correct answer, because the caller cannot tell from the response, and an agent handed an empty list will report "no such parcel" with complete confidence.

My reconnaissance was wrong about Cook, and the tell was sitting there

I read the four obvious datasets, found no floor area, no year built, no bedroom count, and concluded that Cook does not publish building characteristics. The correct conclusion was "these four do not, so where is it". They are in a fifth dataset, and which fifth depends on whether the building is a house or a condominium. Travis repeated the trap in a completely different publishing model: the main file of the export carries no floor area either, and it is in two more files in the same archive.

What I take from it: a missing field is a question. The tell was structural and sitting in front of me: the contract promised a characteristics block and nothing was going to fill it, which is the same signal an agent gets when a section comes back null.

There is no envelope width that is correct

Around a geocoded point, plus or minus 22 meters found my reference house exactly, one candidate, no ambiguity. That width was not a guess, which is why I trusted it: it had just worked perfectly on a detached house. Around a condominium tower it returned zero. Widening to 55 meters returned four units of a different building, as a clean success with nothing in it to say so. San Diego breaks the same route in the opposite direction: one point returned 90 parcels across three street names and $36.9 million of combined value, in 1.5 seconds, no flag and no error, because condominiums, possessory interests and mobile homes are all stacked on the parent parcel's polygon.

What the contract says now: a spatial hit is a candidate set, and it stays one until address attributes narrow it. If several candidates survive, the caller is told the address named a building.

493,246 properties do not fit in a 4 GB heap

The data is small. The objects around it are not, at roughly 8 KB of overhead each once owners, addresses and values are nested. A bigger heap is a monthly bill on this platform, so I went looking for the property that would make one unnecessary, and found that the export happens to be sorted by property id, which the county's own layout spec never mentions. A property is therefore complete the moment a different id appears, and can be written out and forgotten.

What shipped with it: a design leaning on an undocumented property carries its own check. The builder counts out-of-order identifiers instead of trusting the ordering, zero across 493,324 records, and reports that count in the index manifest, so the day it stops being true the output says so.

One thing that has never been tested

Travis supplements are a change log, with add, modify and delete actions applied in order. The certified file I built the index from has supplement number 0 and an empty action on every one of its 493,324 records, so the code handling those actions has never seen a supplement. It is written from a document.

Stated, not implied: untested code is a known limitation and belongs in the open, on the same discipline as the flags. What the caller has to know to avoid being wrong gets said out loud, and that includes what the author has not managed to verify yet.

Apify run log: container pull and SDK boot timestamps, then a line reading Answered in 275 ms.

The run log. Container pull, SDK boot, then the lookup.

The two things only the agent found

Everything above came out of the reconnaissance or out of writing the adapters. Two things came out of handing the finished Actor to an agent and reading what came back, and neither is about a county.

The refusal path had never been normalized, and nobody had noticed, because humans read refusals

The success path had one constructor and a validated vocabulary from the first commit. That is why four adapters written weeks apart produce identical record shapes. Refusals were object literals at five separate call sites, and they had drifted. Three of the five did not name the county at all. The endpoint appeared under three different key names across the five. The one refusal that did name its county had the FIPS code typed into the adapter by hand instead of read from the jurisdiction table.

None of that is visible while the person reading the output is the person who typed the query. You know which county you asked, because you asked one, and it is on your screen. An agent asking four counties about one property gets a flat list back, and three of five possible refusals in it cannot say who refused.

That is the failure the whole contract exists to prevent, sitting on the one path the contract never covered. The rule that every answer names its source had simply never been applied to refusals, because I had never read one from outside.

Refusals now go through one constructor, in the same src/record.js, and it enforces exactly what the success path had enforced all along:

if (!REFUSAL_CODES.has(result)) throw new Error(`unknown refusal result: ${result}`);
if (!reason) throw new Error(`refusal ${result} has no reason`);

Two lines, and they are two lines I could have written on day one. I redeployed and reran the same lookups over MCP: the refusals came back in one shape, and the Cook success record came back with the same 42 fields as before, so the fix left alone what it was meant to leave alone.

The number the platform reports is mostly not the county

The console and the MCP response both say the Cook lookup took 2.81 seconds. The Actor's own log says the lookup took 275 milliseconds. About 2.1 seconds of that run was container pull and SDK boot before the first byte left for Illinois, and the same lookup rerun in November spent about 3.5 seconds there and answered in 213 ms.

The run time and the lookup time are close enough in magnitude to be mistaken for each other, and the routing table in my own contract measures counties while anything measured through a run measures the platform as well. Travis shows it worst: the August Travis run took 2.15 seconds and returned no data at all, because the platform image ships no index.

What it refuses to do

Four limits, each one a decision rather than a gap.

It will not guess the county from an address. The jurisdiction is a required enum with no auto-detect option, though the console preselects San Diego, which I would not do again: a default is a guess too. Resolving an address to a county is a separate problem with its own failure modes, and I would rather refuse the lookup than fold that problem silently into this one. This is less friendly than the alternative, on purpose.

Public documented endpoints only. Travis publishes bulk files and no documented API, and behind its vendor property search there is clearly a backend; the county's own page even links to a staging host for it. I found that, wrote it down, and did not touch it. An undocumented internal endpoint can change without notice, and a staging host is plainly not meant for public traffic. That decision costs coverage, and it is the entire reason Travis is a 531 MB download instead of a portal call.

Tax amounts are out of scope. In all four jurisdictions the amount owed belongs to a treasurer or a collector, a different agency from the assessor. "Property tax lookup" is exactly what a caller will assume it returns, so the Actor says so rather than silently omitting it.

Owner names, where the publishers disagree, are withheld by default. San Diego's assessor served me an owner name on every parcel I queried, unauthenticated, on 2026-08-02 and again on 2026-08-05. The joint city and county GIS agency states that California AB1785 bars it from publishing owner name and address in any publicly accessible online location. Both statements are current, and it is not this Actor's job to settle which one governs.

It does not settle it. It stops shipping the field by default, and the record says what it did and how to change it. Ask for the name explicitly and it comes back carrying a different flag, saying its publication is disputed, so the fact travels with the data instead of ending at the first hop.

Availability is not permission. Shipping whatever an endpoint hands over is not a neutral default either: it picks the same side of that disagreement, silently.

The related case is where the source itself marks a record confidential, which Travis's export can do per owner. There the answer is "this parcel exists and the person is withheld", never "not found", and the Actor does not reconstruct a suppressed owner by joining another source.

What I would keep

Three things generalize past this Actor.

The input schema is a prompt. Its field descriptions are read by the model choosing the arguments, so they are the cheapest place in the whole system to prevent a wrong call. Write them for a reader who has never seen a parcel id, and fit that in 500 characters.

A refusal is a record. Anything an agent can receive has to be as structured as the thing you designed on purpose. And the path you never read yourself is the path that drifts, which means the question to ask about any agent-facing output is not "is this correct" but "who reads this, and what do they already know that the record does not say".

The value is in what the record refuses to flatten: three stages of one year, five rungs of one ladder, a clamp that looks like a measurement, a parcel that stopped existing a decade ago.

Two things I would do differently. I would write the refusal constructor before the fourth adapter rather than after an agent read its output, because the fix took twenty minutes and the drift had been shipping for four versions. And I would decide where the Travis index lives before building it locally: answering index_not_built with the command to run is honest, and it is also a decision I deferred by shipping a message about it.

The Actor is on GitHub: actor-us-tax-county-scraper. Four adapters, the record and refusal constructors, 96 unit tests, 26 live ones against the real counties, which is how I learn when one of them changes something, and the sweep that draws them. It also runs from the Apify Store without cloning anything, for every county but Travis, which answers index_not_built there.

If you are pointing an agent at a source that answers more than one way, tell me what your record refuses to flatten.

About the author.

Mikhail Koviazin is a backend engineer working on data extraction. In 2017 he built Google Maps crawlers on Apifier, as Apify was then called. Between 2020 and 2022 he built a scraping layer on top of Apify SDK v1, the codebase that later became Crawlee, running more than 5 million products a day across 1000+ sites, patching the SDK locally where its public interface did not expose what he needed. He now builds Actors that AI agents call directly.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.