Price a Whole Inventory With an AI Agent: Queries, Confidence, Cost and Retries

A store buyout, a storage unit, a consignment intake, a catalog that needs re-pricing every week: at some point "what's this worth?" becomes 500 rows. An AI agent is good at the part people find tedious (turning messy item descriptions into precise searches, then reviewing the odd ones) and bad at the part a script does better (calling an API 500 times with fixed settings).
So split it. The agent writes queries and reviews flags. A short Python script does the lookups. This guide covers both halves, plus the cost math, caching and retries. It builds on Automate sold comps, which has a simpler script and real example output.
The workflow
- Normalize. The agent reads the inventory and writes one eBay-style query per row, plus a condition when known.
- Estimate. Count the rows, pick a
limit, check your balance withGET /v1/usage(free). - Run. The script calls
GET /v1/soldonce per row, caches each result, and writes the median, middle range, counted sales and a review flag. - Review. The agent (or a person) looks only at the flagged rows: sharpens the query and re-runs, or marks them for manual pricing.
You can drive all four steps from Claude Code, Cursor or any coding agent: give it the CSV, this guide and the agent playbook.
Step 1: write good queries
The query decides the price. A vague query mixes different products, and the median comes out wrong with high confidence. Ask the agent to write each query like an eBay search, using what changes the price:
| Inventory row says | Better query | Why |
|---|---|---|
| "Old Canon camera, works" | canon ae-1 program 50mm | Model and lens, not "camera" |
| "Switch, used" | nintendo switch oled + condition=used | OLED, Lite and original sell differently |
| "Charizard card graded 9" | charizard 4/102 "psa 9" -lot | Card number, quoted grade, no bundles |
| "iPhone 13" | iphone 13 128gb unlocked | Storage and carrier move the price |
| "Levi's jeans" | levis 501 made in usa 32x30 | Model, origin and size |
Rules worth putting in the agent's instructions:
- One item per query. Bundles go in as separate rows.
- Include model or reference numbers, storage, size, edition, set number and grade.
- Quote exact phrases (
"psa 10") and exclude junk with a minus (-lot,-broken,-box). - Set
conditionwhen the item's condition is known:new,open_box,refurbished,usedorfor_parts. - Don't invent details. If the row doesn't say the storage size, leave it out and let the review step catch a wide range.
Matching is strict on numbers: every number in your query must appear in the listing title as a whole number. A wrong model or card number finds nothing, rather than something close.
Step 2: pick limit deliberately
Every successful call costs 20 credits plus 1 per sale returned. limit caps how many sales are returned, so it sets the price. The stats (median, p25, p75, min, max) are computed from every matching sale, up to 200, whatever limit is.
limit | Max credits per item | 500 items | Use when |
|---|---|---|---|
| 1 | 21 | 10,500 | You only need the stats |
| 10 | 30 | 15,000 | Stats plus a few sales to spot-check |
| 20 | 40 | 20,000 | Stats plus enough sales to review flagged rows |
| 50 (default) | 70 | 35,000 | You store recent sales per item |
| 200 | 220 | 110,000 | You need every sale the stats used |
These are maximums: a search that finds fewer sales than limit costs less, and a search with no sales costs 20. Failed calls and /v1/usage are free.
For scale: the free plan's 2,000 credits a month covers 50 items at limit=20. Starter (50,000 credits) covers 500 items at limit=20 with room to re-run. Plans and prepaid credits are on the pricing page.
Step 3: the script
Plain Python with requests, no LLM SDK. Your CSV, inventory.csv:
sku,query,condition,site
A101,canon ae-1 program 50mm,used,EBAY_US
A102,nintendo switch oled,used,EBAY_US
A103,"charizard 4/102 ""psa 9"" -lot",,EBAY_US
A104,levis 501 made in usa 32x30,used,EBAY_US
condition and site are optional; empty means any condition on eBay US.
import csv
import json
import os
import sys
import time
import requests
API = "https://www.soldcomps.io/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SOLDCOMPS_API_KEY']}"}
LIMIT = 20 # sales returned per call: each call costs 20 + up to LIMIT credits
CACHE_FILE = "comps_cache.json"
CACHE_DAYS = 7 # re-use a result for this long before paying for a fresh one
MIN_SALES = 10 # fewer counted sales than this: flag for review
WIDE = 1.5 # p75 more than this many times p25: flag for review
class Stop(Exception):
"""An error that retrying won't fix (out of credits, daily capacity reached)."""
def error_of(r):
try:
return r.json().get("error", {})
except ValueError:
return {}
def fetch(q, condition, site):
params = {"q": q, "site": site, "limit": LIMIT}
if condition:
params["condition"] = condition
for attempt in range(5):
r = requests.get(f"{API}/sold", params=params, headers=HEADERS, timeout=60)
if r.ok:
return r.json()
err = error_of(r)
code = err.get("code")
if r.status_code == 402:
raise Stop(f"out of credits: needs {err.get('credits_needed')}, has {err.get('credits_remaining')}")
if code == "daily_capacity_reached":
raise Stop("free-tier daily capacity reached; re-run after midnight UTC (cached rows are kept)")
if r.status_code == 429 or r.status_code >= 500:
# rate limited or upstream hiccup: never billed, safe to retry
wait = int(r.headers.get("Retry-After", "5")) if r.status_code == 429 else 5 * 2 ** attempt
time.sleep(wait)
continue
return {"error": code or f"http_{r.status_code}", "param": err.get("param")} # 400/401: fix and re-run
return {"error": "gave_up_after_retries"}
def review_flag(d):
if "error" in d:
return f"error: {d['error']}" + (f" ({d['param']})" if d.get("param") else "")
s = d["stats"]
counted = d["count"] - s["excluded"]
if s["median"] is None:
return "no sales"
if counted < MIN_SALES:
return "few sales"
if s["p25"] and s["p75"] and s["p75"] > WIDE * s["p25"]:
return "wide range"
return ""
def main(src="inventory.csv", dst="priced.csv"):
try:
with open(CACHE_FILE) as f:
cache = json.load(f)
except FileNotFoundError:
cache = {}
with open(src, newline="") as f:
rows = list(csv.DictReader(f))
u = requests.get(f"{API}/usage", headers=HEADERS, timeout=30) # free
u.raise_for_status()
usage = u.json()
worst = len(rows) * (20 + LIMIT)
print(f"{len(rows)} rows, at most {worst} credits before cache hits; {usage['total_remaining']} remaining")
spent = 0
try:
with open(dst, "w", newline="") as out:
w = csv.writer(out)
w.writerow(["sku", "query", "condition", "site", "counted", "p25", "median", "p75", "currency", "review"])
for row in rows:
q, cond = row["query"].strip(), (row.get("condition") or "").strip()
site = (row.get("site") or "").strip() or "EBAY_US"
key = f"{site}|{cond}|{q.lower()}"
hit = cache.get(key)
if hit and time.time() - hit["at"] < CACHE_DAYS * 86400:
d = hit["data"]
else:
d = fetch(q, cond, site)
if "error" not in d:
cache[key] = {"at": time.time(), "data": d}
spent += d["credits"]["charged"]
flag = review_flag(d)
s = d.get("stats", {})
counted = d["count"] - s["excluded"] if "error" not in d else ""
w.writerow([row["sku"], q, cond, site, counted, s.get("p25"), s.get("median"), s.get("p75"), s.get("currency"), flag])
print(row["sku"], s.get("median"), flag or "ok")
except Stop as e:
print("stopped:", e, file=sys.stderr)
finally:
with open(CACHE_FILE, "w") as f:
json.dump(cache, f)
print(f"credits spent this run: {spent}")
if __name__ == "__main__":
main(*sys.argv[1:])
Run it:
pip install requests
export SOLDCOMPS_API_KEY=sh_live_...
python price_inventory.py inventory.csv priced.csv
priced.csv has one row per item. The values below are placeholders showing the shape, not real prices:
| sku | query | condition | site | counted | p25 | median | p75 | currency | review |
|---|---|---|---|---|---|---|---|---|---|
| A101 | canon ae-1 program 50mm | used | EBAY_US | <n> | <p25> | <median> | <p75> | USD | |
| A104 | levis 501 made in usa 32x30 | used | EBAY_US | <n> | <p25> | <median> | <p75> | USD | wide range |
For real numbers on a similar CSV, see Automate sold comps.
Step 4: review the flags
The script sorts the inventory into "priced" and "look at this". Each flag has a usual fix:
- few sales (under 10 counted): the median is a rough guide, not a price. Broaden the query (drop a word, remove a quote) or price it by hand. How many sold comps you need explains the cutoff.
- wide range (p75 more than about 1.5x p25): the search probably mixes versions, sizes or conditions. Add the detail that separates them, or set
condition. Tighten the threshold for electronics, loosen it for vintage. - no sales: nothing clean matched in 90 days. Check the query for a wrong number, then broaden it once.
- error: invalid_param (q): the parameter in brackets was rejected, usually an empty or too-long query (2 to 200 characters).
An agent is good at this step. Give it priced.csv and the agent playbook, have it rewrite only the flagged queries, and re-run. Unchanged rows come from the cache and cost nothing.
Caching
The API bills every successful search, including repeats. Your own cache is what saves credits:
- Key by site, condition and query, as the script does.
- Pick a freshness window by category. Cards, GPUs and new consoles move week to week; kitchenware and vintage household goods barely move in a month.
- Keep the dated results. After a few runs you have your own price history per item.
The script also keeps the cache when it stops early, so a re-run picks up where it left off.
Retries and errors
Errors are never billed. The script handles them like this:
| Status | error.code | Script does |
|---|---|---|
| 429 | rate_limited | Waits for Retry-After, retries |
| 502 | upstream_unavailable | Backs off (5, 10, 20, 40 seconds), retries |
| 503 | daily_capacity_reached | Stops: free-tier capacity for new searches is used up until midnight UTC. Paid plans and credits aren't affected |
| 402 | quota_exceeded | Stops and prints credits_needed and credits_remaining |
| 400 | invalid_param | Records the error on that row and moves on |
Rate limits are per key: 30 a minute on Free, up to 300 on Scale. A sequential script rarely hits them; if you parallelize, keep the 429 handling. Full details are in the API docs.
Related
- Agent playbook: the query and interpretation rules to give your agent.
- OpenAI, Agents SDK and Vercel AI SDK tools: the same API as a function tool.
- Build a pricing agent with the Claude API: conversational lookups through MCP.
- eBay sold prices in Claude and in ChatGPT, for small lists in chat.
- Every agent option: /agents. MCP setup: MCP server. What the numbers mean: sold comps.
Frequently asked questions
Can an AI agent price a whole inventory from eBay sold data?
Yes. The agent's job is writing a specific eBay-style query for each item and reviewing the rows a script flags. A batch script calls GET /v1/sold once per item and writes back the median, middle range and counted sales.
How many credits does it take to price 500 items?
Each call costs 20 credits plus 1 per sale returned. At limit=20 that's at most 40 credits per item, so at most 20,000 credits for 500 items, which fits the Starter plan's 50,000. Cached rows and failed calls cost nothing.
Which rows should a person review?
Rows with fewer than 10 counted sales, rows where the 75th percentile is more than about 1.5 times the 25th, rows with no sales at all, and anything high-value or easy to fake. The script flags the first three automatically.
Should I retry when the API returns an error?
Retry 429 after the Retry-After header and 502 with backoff. Don't retry 402 quota_exceeded (out of credits) or 503 daily_capacity_reached (free-tier capacity is used up until midnight UTC). Errors are never billed.
Why use the API instead of the MCP tool for a big list?
The MCP tool is built for conversational lookups and lists up to 20 sales. For a spreadsheet you want a script: a fixed limit for predictable cost, filters for price range and buying format, your own cache, and results written straight to a file.