Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Build a Private-Company Research Agent (API and Claude)

Pull profiles and decision makers for private companies, then make the model say what it does not know. Real queries, code and the record cost.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

A private-company research agent works when it treats missing data as normal. Pull what the database holds, hand the model only that, and make it flag every gap instead of filling it.

This guide builds one in Python. LeadOcean supplies company profiles and people. Claude Code writes the brief. A script glues them together.

TL;DR

  • It takes about 60 minutes. Sizing is free, and the test run is a 10-company batch.
  • The batch spends about 50 records: 10 from search, 10 from enrich, 30 from a 3-person roster per company.
  • The agent never invents a field. Each brief lists what is missing, and low-confidence briefs go to a human.

Most guides on this topic start from public companies with clean filings. Private companies rarely have them. Revenue, funding and headcount are often blank or stale, so a scoring layer that trusts them inherits the error. The design below assumes gaps and records them. Crustdata's private-company agent guide (September 2026) makes the same point at more length. This version is shorter and runs.

The pipeline has five stages. Size the universe, pull a batch, enrich each company, add a short roster of people, and ask the model for a brief. Only the last stage uses a model. Everything before it is plain HTTP, so you can rerun any stage on its own and diff the output.

Prerequisites

  • A LeadOcean API key from app.leadocean.io. The free plan has 1,000 records, one-off, no card.
  • The key in your shell as $LEADOCEAN_API_KEY.
  • python3, curl and jq.
  • Claude Code for the brief step. Any model that returns JSON works if you swap the last call.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"

Step 1: Size the private-company universe for free

Count before you spend. A sizing call returns meta.total and no rows, so it costs nothing. Send count=true and limit=1 as query parameters, with arrays comma-separated.

bash
curl -s -G https://api.leadocean.io/v1/companies/search \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  --data-urlencode "companyType=Privately Held" \
  --data-urlencode "industry=Software Development" \
  --data-urlencode "employeeRange=51-200" \
  --data-urlencode "hqCountry=US" \
  --data-urlencode "count=true" \
  --data-urlencode "limit=1" | jq '.meta | {total, totalIsExact, countOnly}'

meta.total stops at 100,000, and totalIsExact is false when the number is a floor. Run this before every batch. A total of zero means a filter is wrong, and a total in the millions means you should narrow it. Enum values are exact strings, so look them up with GET /v1/enums/company_type or the free leadocean_list_enum_values MCP tool. "Privately Held" is one of ten company types.

Step 2: Pull a first batch of companies

Search with the same filters and a hard limit. Each company returned is one record, so ten rows cost 10. The body is flat JSON and every filter is an array.

bash
curl -s https://api.leadocean.io/v1/companies/search \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "Content-Type: application/json" \
  -d '{"companyType":["Privately Held"],"industry":["Software Development"],
       "employeeRange":["51-200"],"hqCountry":["US"],"limit":10}' > batch.json

jq '.data[0] | keys' batch.json
jq -r '[. | .domain? // empty] | unique[]' batch.json > targets.txt
wc -l targets.txt

Start with ten rows. You can raise limit to 100 later and page with cursor. The OpenAPI schema types company_data only as an object. So inspect one row first, as the second command does. If your rows name the domain field differently, change the jq path.

Step 3: Enrich each company

Search rows are thin. Enrich returns the full company record: company_data, company_detected_technologies, company_metrics and meta. Send one domain per call. Enrich takes a domain or a linkedin_url for a company, so the targets file is plain domains.

bash
mkdir -p raw
while read -r d; do
  curl -s https://api.leadocean.io/v1/companies/enrich \
    -H "x-api-key: $LEADOCEAN_API_KEY" -H "Content-Type: application/json" \
    -d "{\"domain\":\"$d\"}" > "raw/$d.company.json"
done < targets.txt

Budget one record per call to be safe. A company we hold nothing for is a 404. Keep that file. The empty answer is part of the research, and the agent should say "not found" rather than guess.

Step 4: Add the people who matter

The roster endpoint returns thin people records for one domain. Cap it with limit, and note that each person returned is one record. Contact points are not included, so this step spends no email or phone lookups.

bash
while read -r d; do
  curl -s "https://api.leadocean.io/v1/companies/$d/people?limit=3" \
    -H "x-api-key: $LEADOCEAN_API_KEY" > "raw/$d.people.json"
  sleep 0.05
done < targets.txt

The roster is how the agent answers "who runs this". Three rows is enough for a first read, and a founder or head of function shows up in most small companies. Three people at ten companies is 30 records. Page with meta.nextCursor if you want more, and expect the roster to stop at 10,000 rows.

Step 5: Make the model name what is missing

Give the model the raw files and one rule: use only what is in them. The prompt carries the full JSON, so the model sees exactly what you paid for. The schema forces a confidence level and a list of gaps. This is where a private-company agent earns trust, because most records are incomplete. A brief that says "revenue: missing" is useful. A brief that quotes a revenue figure nobody supplied is a liability.

Save this as brief.py.

python
import glob, json, os, subprocess

SCHEMA = json.dumps({"type": "object", "properties": {
  "summary": {"type": "string"},
  "signals": {"type": "array", "items": {"type": "string"}},
  "missing": {"type": "array", "items": {"type": "string"}},
  "confidence": {"type": "string", "enum": ["high", "medium", "low"]},
  "needs_human_review": {"type": "boolean"}},
  "required": ["summary", "signals", "missing", "confidence", "needs_human_review"]})

RULES = ("Use ONLY the JSON below. Never infer revenue, funding or headcount "
         "that is not in it. List every field you wanted and could not find "
         "under missing. If the company record is empty or a 404, set "
         "confidence low and needs_human_review true.")

for path in sorted(glob.glob("raw/*.company.json")):
    domain = os.path.basename(path).replace(".company.json", "")
    people = open(f"raw/{domain}.people.json").read()
    prompt = f"{RULES}\n\nCOMPANY:\n{open(path).read()}\n\nPEOPLE:\n{people}"
    out = subprocess.run(
        ["claude", "--bare", "-p", prompt, "--output-format", "json",
         "--json-schema", SCHEMA], capture_output=True, text=True, check=True)
    brief = json.loads(out.stdout).get("structured_output") or {
        "confidence": "low", "needs_human_review": True}
    print(json.dumps({"domain": domain, **brief}))
bash
python3 brief.py > briefs.jsonl
jq -r 'select(.needs_human_review) | .domain' briefs.jsonl

Claude's headless docs recommend --bare for scripted calls, which reads ANTHROPIC_API_KEY from your environment. This step spends Claude tokens, not LeadOcean records. The second command lists the domains a human should read first. Review those, then spot-check a few high briefs against the raw files.

What you get

Step 1 gave you a free count of the private-company universe. For the Claude-side view, the free MCP tools list the exact enum values, so the agent never guesses a filter. Here is the company-type lookup, run on 2026-10-21.

json
{"name":"company_type","description":"Legal/organisational type of the company.",
 "total":10,"matched":10,
 "values":["Educational","Educational Institution","Government Agency",
 "Nonprofit","Partnership","Privately Held","Public Company","Self-Employed",
 "Self-Owned","Sole Proprietorship"],"truncated":false}

The Step 1 filters (Privately Held, Software Development, 51-200 employees, US headquarters) matched 6,376 companies in the free leadocean_count_companies tool on 2026-10-21. The same filters in leadocean_count_leads matched 747,985 people, 212,324 of them with a verified or catch-all email, across 5,271 employers.

The enrich call returns four top-level blocks. This is the response shape from the API reference, with placeholder values:

json
{
  "success": true,
  "data": {
    "company_data": { "...": "profile fields for acme.com" },
    "company_detected_technologies": { "...": "detected on the site" },
    "company_metrics": { "...": "metrics" },
    "meta": { "...": "source and schema" }
  }
}

Each line of briefs.jsonl then looks like this. Values are placeholders, not a real run.

json
{"domain":"acme.com","summary":"...","signals":["..."],
 "missing":["revenue","funding total"],"confidence":"medium",
 "needs_human_review":false}

Read the missing array first. If every brief lists revenue and funding, the lesson is that this segment is thin on financial data, and your scoring should lean on the other fields. If only a few do, those few are the ones a person should research by hand. Either way you learn it before anything reaches a rep.

Troubleshooting

ErrorCauseFix
403 on search or enrichThe key lacks the search or enrich scopeCreate a key with both scopes in the app
404 on company enrichWe hold nothing for that domainKeep the file, set confidence low, send it to review
429Above your plan's rate limit (5 a second on Free, 50 on Solo, 200 on Agency)Wait for Retry-After, then retry; the roster loop sleeps 50 ms
402The 1,000 free records are spentCheck GET /v1/account, lower limit, or move to Solo ($97) or Agency ($497)
meta.total is null with totalWithheldA filter could not be applied, so the count is withheldDrop or narrow that filter and count again
Empty resultsA filter value is not an exact enum stringLook it up with GET /v1/enums/{name} and resend

FAQ

How many records does one company cost?

About 5 in this build: 1 search row, 1 enrich and 3 roster people. Cut the roster to 1 and it drops to 3. Paid plans have no per-record price: Solo is $97 a month and Agency $497, each with a fair-use pace.

How do I scale past ten companies?

Raise the search limit to 100 and page with cursor. Keep the same loops, and add a seen.txt file so a rerun skips domains you already pulled. Check GET /v1/account before each batch so a long run stops before it runs out of records. Sizing in Step 1 tells you how big the full universe is before you commit.

Does this cover public companies too?

Yes. Change companyType to "Public Company" in Steps 1 and 2. The rest of the pipeline is identical.

What should a human check?

Start with every brief where needs_human_review is true, then sample five high briefs against raw/. Compare the summary line by line with the source JSON. If the model states a number that is not in the file, tighten the rules and rerun that company.

Why not let the model search the web for the gaps?

You can add it as a sixth step. Keep it separate, and label web-sourced facts in the brief so a reviewer sees which came from the database and which did not.

Can I use the MCP server instead of REST?

Yes. The hosted server at https://api.leadocean.io/mcp signs in with OAuth and needs no key in the chat. Use it to size and explore. Use the script for batches.

Build your private-company research agent on a free key

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →