# Parceled — Full Agent Reference Parceled is a real-estate data platform for AI agents. Core offering: 1. Parcel boundaries, served as Mapbox Vector Tiles you can plug into any Mapbox GL JS or MapLibre map. 135M+ parcels, all 50 states. 2. Assessor-sourced property records (address, APN, owners, land size, sale history) queryable per coordinate. 3. A hosted vector tile server — no infrastructure on your side. Supplementary enrichment datasets: - Roof permits (from jurisdictional permit offices, coverage varies) - Hail swath history (NWS Storm Prediction Center) This document covers transport, auth, every tool, error modes, and an end-to-end example. If you are an LLM reasoning about how to integrate with Parceled, read this top to bottom. --- ## 1. Transport Parceled exposes MCP over Streamable HTTP at: POST https://parceled.ai/api/mcp Content-Type: application/json Authorization: Bearer Protocol version: 2025-11-25. The server also accepts the same schema as a plain GraphQL endpoint at https://parceled.ai/api for clients that do not speak MCP. ### Initialize handshake { "jsonrpc":"2.0", "id":0, "method":"initialize", "params": { "protocolVersion":"2025-11-25", "clientInfo":{"name":"my-agent","version":"0.1.0"}, "capabilities":{} } } Response includes server info, logo URI, and capability flags for tools and resources. Then: { "jsonrpc":"2.0", "method":"notifications/initialized" } Then tools/list to see the current surface. --- ## 2. Auth Two supported mechanisms. ### 2a. OAuth 2.1 + PKCE + Dynamic Client Registration Metadata chain (RFC 9728 / RFC 8414): GET https://parceled.ai/.well-known/oauth-protected-resource → authorization_servers: [ "https://parceled.ai" ] → resource: "https://parceled.ai/api/mcp" GET https://parceled.ai/.well-known/oauth-authorization-server → issuer: https://parceled.ai → authorization_endpoint: https://parceled.ai/oauth/authorize → token_endpoint: https://parceled.ai/oauth/token → registration_endpoint: https://parceled.ai/oauth/register (DCR, RFC 7591) → code_challenge_methods_supported: ["S256"] → token_endpoint_auth_methods_supported: ["none"] → scopes_supported: ["parceled"] Flow: 1. Client POSTs to /oauth/register with { redirect_uris, client_name } → gets client_id. 2. Client redirects user to /oauth/authorize?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256&redirect_uri=... 3. User receives magic-link email; click → returns to redirect_uri with code. 4. Client POSTs to /oauth/token with grant_type=authorization_code, code, code_verifier, client_id, redirect_uri. 5. Token is a Parceled API key; use as Bearer. Rate limits on magic-link issuance: 5 per email per 15 min, 100/hr global. ### 2b. Bearer API key Keys are `prc_live_…`. There is no web dashboard. Three ways to get one: 1. Complete the OAuth flow above (any compliant MCP client does this automatically on first tool call). The token returned from /oauth/token IS the Parceled API key. 2. REST bootstrap — unauthenticated POST, best for scripts / GPT Actions / LangChain / Zapier. Email is OPTIONAL: With email (recoverable — OAuth can rotate the key later): curl -X POST https://parceled.ai/api/v1/accounts \ -H 'Content-Type: application/json' \ -d '{"email":"you@example.com","organizationName":"Acme Corp"}' Anonymous (no email — for agents that don't have one): curl -X POST https://parceled.ai/api/v1/accounts \ -H 'Content-Type: application/json' \ -d '{}' Response (HTTP 201): { "accountId": "...", "apiKey": "prc_live_...", "apiKeyId": "...", "keyPrefix": "prc_live_abc", "tier": "free", "anonymous": false, "message": "Store the apiKey now — only returned once.", "nextSteps": { "probeDataAvailability": "...", "fetchParcel": "...", "docs": "..." } } Recovery posture: - email provided (anonymous: false) — run OAuth with the same email on any MCP client to rotate into a fresh key. Credits, tier, and usage history are preserved on the existing account. - anonymous (anonymous: true) — the apiKey is the ONLY credential. Lose it and the account is stranded. Create a new one if you need a fresh start. Error responses: - 400 bad_request — malformed email (if provided) or oversized organizationName - 409 conflict — that email already has an account (doesn't apply to anonymous) - 429 rate_limited — Retry-After header included (10/IP/hour) 3. GraphQL — same bootstrap via the `createParceledAccount` mutation on the /api endpoint. Semantically identical; use this if you already speak GraphQL: mutation { createParceledAccount(email: "you@example.com") { apiKey anonymous } } # or anonymous: mutation { createParceledAccount { apiKey anonymous } } Pass the key as: Authorization: Bearer prc_live_… ### 401 behavior Any request without a Bearer token returns: HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://parceled.ai/.well-known/oauth-protected-resource" MCP clients that follow RFC 9728 will auto-redirect to the OAuth flow. --- ## 3. Tool surface ### Free tools (cost 0) #### check_availability(lat, lng) OR check_availability(parcelId) Returns which datasets exist for that coordinate (or parcel). Always call this first. Booleans only — no parcel data, no PII, zero credits. Response shape: { "parcel": true, "roofPermits": true, "hailAreas": true, "currentHailAreas": false } #### get_pricing() Current per-field credit costs + tier definitions. Call once per session to cache; costs change rarely but do change. #### get_balance() { "credits": 87, "tier": "free" | "credits" | "payg", "budgetCapUsd": 50.0 } #### get_usage_history(limit?, since?) Recent tool calls with per-call cost breakdown. #### get_layer_sources() Available map tile layers — layerKey, layer, layerType, minZoom, paint styles. #### get_tile_endpoints() Signed tile URLs for the requesting key (short TTL). #### render_map({ layers?, center?, zoom?, filters? }) Returns an MCP "mcp-app" resource — an inline Mapbox map renders in the conversation. Tile fetches are billed via the standard tile meter (1 credit per 1,000). Filters use Mapbox GL expression syntax, e.g. `["==", "city", "Denver"]` or `["all", ["==","state","CO"], [">=","zip5","80200"]]`. Filterable fields per layer are listed dynamically in the tool description. #### enable_payg() Inline Stripe card form. After setup, calls are billed monthly. #### purchase_credits({ creditAmount: 1000 | 5000 | 25000 }) Inline Stripe checkout. Credits never expire and are account-wide. #### update_budget_cap({ monthlyBudgetCap: number | null }) Set the PAYG monthly spend cap in credits (null to remove). Server-enforced: every billable call runs a pre-flight check; once spent + maxCost would exceed the cap, the server returns a billing_error (HTTP 402 on REST). Counter resets on the 1st of each calendar month. ### Billable tools #### search_parcels({ lat, lng, include? }) The main data query. `include` is an array of fields you want. **Default if `include` is omitted (since protocol v1.1.0):** ["parcel"] only — boundary, address, APN, owner-masked. 1-credit ceiling. Pass `include` explicitly to fetch permits, hail, etc. { "name": "search_parcels", "arguments": { "lat": 39.7392, "lng": -104.9903, "include": ["parcel", "hailAreas"] } } Response is a JSON object matching the GraphQL HailProProperty type. Each top-level key is only present if it was in `include` AND had data: { "parcel": { "id": "08031_0615204102102", // internal stable id "parcelId": null, // county-issued APN if available "address": "665 S CLINTON ST APT 1B", "city": "DENVER", "county": "08031", // FIPS code "state": "CO", "zipcode": "80247", "latitude": 39.70430183875734, "longitude": -104.87952553895568, "landSizeAcres": null, "landSizeSqft": null, "owner": null, // primary owner name (when available) "owner2": null, "ownerAddress": null, "ownerCity": null, "ownerState": null, "ownerZip": null, "saleDate": null, "salePrice": null, "url": null // assessor URL when known }, "roofPermits": [ { "permitId": "BLD-2024-12345", "date": "2024-07-12T00:00:00.000Z", "status": "issued", // issued | finaled | expired etc — varies by jurisdiction "recordType": "Roofing — Residential", "parcelNumber": "0615204102102", "permitUrl": "https://citizenportal.example.com/...", "address": "665 S CLINTON ST APT 1B", "county": "Denver", "state": "CO", "metadata": { /* raw fields from the source jurisdiction, varies */ } } ], "hailAreas": [ { "id": 12345, "ogcFid": 12345, "maxMesh": 45.6, // mm — Maximum Estimated Size of Hail "importDate": 1717221600000, // epoch ms (BigInt) "source": "NWS-SPC", "zIndex": 1 } ], "currentHailAreas": [ // same shape as hailAreas; only swaths from the last 14 days ] } NOTE: parcel boundary polygons are NOT returned in this query. Tile-based boundary rendering is available via get_tile_endpoints + the tile URLs (billed separately at 1 credit per 1,000 tile requests). Owner names are returned as-is when available; we do not currently mask them. A second text block in the MCP tool result carries the billing summary: { "totalCost": 2, "fieldsCharged": [{ "field":"parcel", "label":"Parcel data", "cost":1 }, { "field":"hailAreas","label":"Hail history","cost":1 }], "fieldsEmpty": [], "remainingCredits": 85, "tier": "credits" } A third text block carries a `hints` object that teaches you the surface without a discovery round-trip. Every billed response has it: { "requestedFields": ["parcel", "hailAreas"], "otherAvailableFields": [ { "field":"roofPermits", "cost":2, "label":"Roof permits" }, { "field":"currentHailAreas", "cost":1, "label":"Current hail areas" } ], "message": "Other fields available on this query: roofPermits (2 credits), currentHailAreas (1 credit). To add one, pass include=[\"parcel\",\"hailAreas\",\"roofPermits\"]. To check which datasets actually exist at this location before paying, call check_availability (free).", "appliedDefault": "No 'include' was passed, so the safe default [\"parcel\"] was used (1-credit ceiling). Pass an explicit 'include' array to fetch additional fields." } The `appliedDefault` key is only present when the caller omitted `include` and the server fell back to the safe default. --- ## 4. Field cost table | Query | Field | Cost (credits) | Label | |----------------|------------------|----------------|------------------------| | search_parcels | parcel | 1 | Parcel data | | search_parcels | roofPermits | 2 | Roof permits | | search_parcels | hailAreas | 1 | Hail history | | search_parcels | currentHailAreas | 1 | Current hail areas | Fields-with-no-data are free. Flat-rate tools (all the free ones above) never consume credits regardless of result shape. --- ## 4b. Embedding Parceled tiles in your own Mapbox / MapLibre map This is the primary integration path for real-estate apps. Parceled hosts vector tiles for every layer; you don't need to run a tile server. Use this any time you want to render parcel polygons (or hail swaths, etc.) inside a map you control, instead of inside the inline render_map viewer. Step 1. Get tile endpoint templates (free): tools/call get_tile_endpoints (or GraphQL: query { getTileEndpoints { layerId layerName urlTemplate minZoom maxZoom format description } }) → returns one entry per layer: { "layerId": "", "layerName": "parcels_a", // Mapbox "source-layer" name "urlTemplate": "/api/parceled/tiles//{z}/{x}/{y}?key={api_key}", "minZoom": 0, "maxZoom": 14, "format": "pbf (Mapbox Vector Tiles)" } Step 2. Substitute the API key server-side (NEVER put plaintext keys in browser-exposed JS) and prepend https://parceled.ai to the path. Step 3. Wire into Mapbox GL JS: map.addSource('parceled-parcels', { type: 'vector', tiles: [ 'https://parceled.ai/api/parceled/tiles//{z}/{x}/{y}?key=prc_live_...' ], minzoom: 0, maxzoom: 14 }) map.addLayer({ id: 'parcels-fill', type: 'fill', source: 'parceled-parcels', 'source-layer': 'parcels_a', // must match layerName from step 1 paint: { 'fill-color': 'hsla(22, 100%, 50%, 0.15)', 'fill-outline-color': 'hsla(22, 100%, 50%, 1)' } }) Same pattern works in MapLibre GL JS (the open-source fork) without any Mapbox credentials. Parceled tiles are unencumbered Mapbox-spec MVTs. Step 4. Filtering. Call `get_layer_sources` once to see each layer's fields[].name — those are the ONLY names you can reference in a Mapbox filter expression on that layer. A filter that references a field not in that list will match zero features and the map will render empty. Server tools (render_map) validate this and return errors; a map in your own browser will silently render blank. map.setFilter('parcels-fill', ['==', 'zip5', '80203']) // OK if 'zip5' is in get_layer_sources Step 5. Billing. Tile requests are metered separately from API calls: - 5,000 tile requests / month free (free tier) - 1 credit per 1,000 tile requests beyond that Tiles are cached aggressively at the edge, so real-world costs at typical usage are low. Free tier usually suffices for small apps. Backend proxy pattern (recommended for browser UIs): serve `/tiles/{z}/{x}/{y}` from your own backend, inject the Parceled API key server-side, forward to parceled.ai. Keeps the key private and lets you add your own rate limiting / auth if needed. --- ## 5. Error modes and how to recover - HTTP 401 + WWW-Authenticate → Follow OAuth discovery. Do not retry same-token. - JSON-RPC error code -32001 "Authorization required" → Same as 401. Token is missing. - JSON-RPC error code -32001 "Invalid or inactive API key" → Key was revoked or account deactivated. User must re-auth. - JSON-RPC error code -32602 "Unknown tool" → Refresh tools/list. A tool may have been renamed or removed. - Tool result with `isError: true` and content[0].text = `{"error":"billing_error","message":…,"maxCost":N}` → Pre-flight billing check failed. Three sub-cases, distinguishable from the message: - Free tier limit reached ("Free tier limit reached (100 calls/month)…") → call purchase_credits or enable_payg. - Out of credits on the credits tier ("Insufficient credits…") → call purchase_credits. - Over budget cap on PAYG ("Monthly budget cap would be exceeded. Spent X of Y credits…") → call update_budget_cap to raise/remove the cap, or wait until the 1st of next month when the counter resets. REST equivalent: HTTP 402 with the same body shape. - Tool result with `isError: true` and content[0].text = `{"error":""}` → Upstream GraphQL error. Do not retry automatically; surface to user. Failed queries are NOT billed. - HTTP 429 with Retry-After header → Wait the number of seconds in the header; exponential backoff after. --- ## 6. Data coverage (current) - Parcels: 135M+ across 1,000+ counties, all 50 US states - Roof permits: partial — coverage varies by jurisdiction. check_availability is the truth. - Hail areas: nationwide via NWS SPC data, back to 2010 - Tile layers: parcels (boundary polygons), hail (storm swaths) — see get_layer_sources and get_tile_endpoints - Refresh: parcels monthly, permits weekly where available, hail daily Coverage matrix is not yet exposed as a tool. For now, check_availability is authoritative per coordinate. --- ## 7. What we do NOT have Do not waste a call asking for these; they don't exist yet: - MLS / for-sale listings - Zestimate-style AVM (automated valuation) - Rental / Airbnb data - School ratings or district boundaries - FEMA flood zones - Demographics / census blocks - Street-level imagery - Unmasked owner PII (owner names returned masked — contact us for redaction-lifted access) Planned (stubbed in field-costs.ts, will light up as datasets land): taxAssessor, recorders, floodZone, avm, schools, demographics, unmaskedOwners. --- ## 8. End-to-end example: "what hail hit this roof" Goal: given an address, tell the user if it has recent hail damage and how much it would cost to confirm with permits. Steps: # 1. Geocode externally (Parceled does not geocode — bring your own lat/lng) # Free options: # - Nominatim (OSM): https://nominatim.openstreetmap.org/search?q=&format=json # (1 rps; User-Agent header required) # - US Census: https://geocoding.geo.census.gov/geocoder/locations/onelineaddress?address=&benchmark=Public_AR_Current&format=json # (US only; no auth) # Paid: Mapbox, Google, Smarty. Any returns { lat, lng } — that is all Parceled needs. # 2. Check availability (free) tools/call check_availability { lat, lng } # 3. If checkAvailability returned hailAreas==true && parcel==true: # fetch parcel + hail, skip permits for now tools/call search_parcels { lat, lng, include: ["parcel", "currentHailAreas"] } # cost: 1 (parcel) + 1 if currentHailAreas non-empty, else 1 # 4. If user wants to verify with permits, separate call: tools/call search_parcels { lat, lng, include: ["roofPermits"] } # cost: 2 if permits exist, else 0 Total worst case: 4 credits = $0.04 PAYG or $0.032 at 20% pack. --- ## 9. Support, abuse, status - Support: hello@parceled.ai - Security / abuse: security@parceled.ai - Status page: https://parceled.ai (footer) --- ## 10. Change log conventions Parceled follows semver on /api/mcp. Breaking changes will: 1. Bump the protocolVersion returned from initialize. 2. Announce in the `.well-known/mcp.json` manifest `version` field. 3. Keep the old shape working for ≥ 30 days. Last updated: see https://parceled.ai/.well-known/mcp.json