Metadata-Version: 2.5
Name: groundtruth-company
Version: 0.1.1
Summary: One call for everything public about a company, from its domain: registrar, DNS, tech stack, hiring, and how to reach it. Python client for the Ground Truth API. No dependencies.
Project-URL: Homepage, https://companyfacts.dev
Project-URL: Documentation, https://companyfacts.dev/llms.txt
Project-URL: Pricing, https://companyfacts.dev/pricing
License: MIT
License-File: LICENSE
Keywords: ai-agents,company-data,company-enrichment,company-lookup,contact-info,crewai,domain-lookup,hiring-data,langchain,llamaindex,mcp,tech-stack,x402
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# groundtruth-company

Give it a company's website domain. Get back what is publicly known about the company, in one JSON object, with no scraping code and no dependencies.

```
pip install groundtruth-company
```

```python
from groundtruth_company import Client

client = Client()                      # free tier, no key
info = client.company("stripe.com")
print(info["hiring"]["open_roles"], info["web"]["email_provider"])
```

Python 3.9+. Standard library only.

## What one call returns

**`client.company(domain, name=None, country=None, ticker=None)`** runs several lookups in parallel and joins them:

| Part | What is in it |
|---|---|
| `web` | Domain record: registrar, created date, email provider, DNS, DMARC |
| `tech` | The website's tech stack: CMS, analytics, CDN, frameworks, payment processors, each with a confidence |
| `hiring` | From the company's own job board: open roles by department and place, salary ranges where published, roles opened and closed in the last 30 days |
| `registry` | The official company register entry. Add `name` and `country` (gb, no, fr, fi, cz, sk, ie, au, or global for LEI) |
| `sec` | Reported revenue from SEC filings. Add `ticker` (US public companies) |
| `missing` | Every part that had nothing to report, with the reason |

Each part stands alone: if one source is down or has nothing, the rest still answer and `missing` says why. Only a call where every part failed is an error, and that is never charged. Every item links to its source so you can check it.

```python
{
  "domain": "stripe.com",
  "parts_found": ["web", "tech", "hiring"],
  "web": {"registrar": "SafeNames Ltd.", "created": "1995-09-12", "email_provider": "Google Workspace", "dmarc": "reject"},
  "tech": {"count": 21, "technologies": [{"name": "Cloudflare", "categories": ["CDN"], "confidence": 100}]},
  "hiring": {"ats": "greenhouse", "board": "stripe", "open_roles": 412, "recent": {"window_days": 30, "opened": 37, "closed": 22}},
  "missing": [{"part": "registry", "reason": "give name and country to add the official register entry"}]
}
```

(Trimmed to the shape. Real answers carry more fields.)

**`client.contacts(domain)`** reads the company's own website (home, contact, about, team and careers pages) and returns what the company published there:

- phone numbers
- role inboxes such as `sales@` and `info@`
- the contact page and the postal address
- for US companies only, every other address at the company's own domain, exactly as published (people, departments, locations)

Every item carries the page it came from. Emails are never guessed from a pattern, addresses at other domains are left out, and robots.txt is honored. Results are cached for 30 days, so a site is read about once a month however often it is asked for.

```python
{
  "domain": "example-dental.com", "us_company": True,
  "contact_page": "https://example-dental.com/contact",
  "phones": [{"number": "+1 512-555-0142", "source_url": "https://example-dental.com/contact"}],
  "role_emails": [{"email": "frontdesk@example-dental.com", "source_url": "https://example-dental.com/contact"}],
  "other_emails": [{"email": "dr.lee@example-dental.com", "source_url": "https://example-dental.com/team"}],
  "address": {"street": "100 Main St", "city": "Austin", "region": "TX", "postal_code": "78701", "country": "US", "source_url": "https://example-dental.com/"},
  "pages_read": [{"url": "https://example-dental.com/", "kind": "home"}],
  "notes": [], "fetched_at": "2026-10-30T18:00:00.000Z", "cached": False
}
```

## Prices

| | |
|---|---|
| Free tier | 10 calls a day per caller, no key, no sign-up |
| $10 once | 2,000 calls that never expire. [Buy](https://buy.stripe.com/dRm7sM04G4iManHeO51Jm00) |
| $49 a month | 20,000 calls a month, cancel any time. [Buy](https://buy.stripe.com/9B6aEYg3EbLe8fz35n1Jm01) |
| Pay per call, no account | x402 (USDC on Base) at `/x402/v1/company` and `/x402/v1/company-contacts` |

A company lookup uses 2 of a key's calls (so $10 is about 1,000 lookups, or $0.01 each). A contacts lookup uses 4 (about $0.02 each). Free-tier calls count as one each. A call that fails is not charged: a key gets its calls back.

The key is shown on the page Stripe sends you to after paying. Use it like this:

```python
client = Client(api_key="your-key")
```

Live prices: https://companyfacts.dev/pricing

## Errors

Every error is a subclass of `GroundTruthError` and carries `.status` and `.body`.

| Exception | HTTP | Meaning |
|---|---|---|
| `BadInputError` | 400 | Missing or malformed input, for example not a domain. Not charged |
| `InvalidKeyError` | 401 | The key is not active |
| `PaymentRequiredError` | 402 (also 429) | Free allowance used up, or the key has no calls left. The message includes the buy links; also `.buy_url` and `.monthly_url` |
| `NotFoundError` | 404 | Nothing found, for example an unregistered domain. Not charged |
| `SiteBlockedError` | 422 | The company's site blocks automated readers. Not charged |
| `UpstreamError` | 5xx, or no connection | An upstream source failed. Not charged, safe to retry |

```python
from groundtruth_company import Client, PaymentRequiredError, NotFoundError

try:
    info = Client().company("stripe.com")
except PaymentRequiredError as e:
    print(e)            # says what happened and where to buy a key
except NotFoundError:
    print("no such company")
```

## Examples

The tool functions below return the error message as text instead of raising, so an agent can read it and tell the user (including the buy link). Each example cites the framework docs it was written against.

### Plain Python

```python
import json
from groundtruth_company import Client

client = Client()  # or Client(api_key="...")

company = client.company("stripe.com", name="Stripe")
print(json.dumps(company["hiring"], indent=2))

reach = client.contacts("example-dental.com")
for phone in reach["phones"]:
    print(phone["number"], "from", phone["source_url"])
```

### LangChain

A `@tool` wrapper:

```python
# Docs: https://docs.langchain.com/oss/python/langchain/tools
import json
from langchain.agents import create_agent
from langchain.tools import tool
from groundtruth_company import Client, GroundTruthError

gt = Client()  # or Client(api_key="...")


@tool
def company_lookup(domain: str) -> str:
    """Everything public about a company from its website domain: registrar, DNS and email
    provider, tech stack, and hiring (open roles, salary ranges, recent openings and closings)."""
    try:
        return json.dumps(gt.company(domain))
    except GroundTruthError as e:
        return str(e)


@tool
def company_contacts(domain: str) -> str:
    """How to reach a company, from its own website: phones, role inboxes such as sales@,
    postal address, and the contact page. Every item names its source page."""
    try:
        return json.dumps(gt.contacts(domain))
    except GroundTruthError as e:
        return str(e)


agent = create_agent(model="openai:gpt-5.5", tools=[company_lookup, company_contacts])
result = agent.invoke({"messages": [{"role": "user", "content": "Is stripe.com hiring? What do they run on?"}]})
```

The remote MCP endpoint (`company_lookup` is in it; `company-contacts` is REST and x402 only for now). Current LangChain moved MCP into `langchain.mcp`:

```python
# Docs: https://docs.langchain.com/oss/python/langchain/mcp
#       https://docs.langchain.com/oss/python/langchain/mcp/auth.md  (bearer token via a FastMCP client)
# pip install "langchain[mcp]"
from fastmcp.client import Client
from langchain.agents import create_agent
from langchain.mcp import MCPAdapter

URL = "https://companyfacts.dev/mcp"


async def main():
    # Free tier, no key:
    async with MCPAdapter(URL) as adapter:
        tools = await adapter.list_tools()
        agent = create_agent("openai:gpt-5.5", tools)
        return await agent.ainvoke({"messages": [{"role": "user", "content": "Look up stripe.com"}]})


# With a key, pass a FastMCP client that carries it:
#   async with MCPAdapter(Client(URL, auth="your-key")) as adapter: ...
```

With the older `langchain-mcp-adapters` package (its repository is archived, and its README points to `langchain.mcp` above, but it still works):

```python
# Docs: https://github.com/langchain-ai/langchain-mcp-adapters
# pip install langchain-mcp-adapters
from langchain_mcp_adapters.client import MultiServerMCPClient

client = MultiServerMCPClient({
    "groundtruth": {
        "transport": "http",
        "url": "https://companyfacts.dev/mcp",
        # "headers": {"Authorization": "Bearer your-key"},   # optional, lifts the free tier
    }
})
tools = await client.get_tools()
```

### CrewAI

A custom `BaseTool`:

```python
# Docs: https://docs.crewai.com/en/learn/create-custom-tools
import json
from typing import Type

from crewai import Agent, Crew, Task
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
from groundtruth_company import Client, GroundTruthError

gt = Client()  # or Client(api_key="...")


class DomainInput(BaseModel):
    domain: str = Field(..., description="The company's website domain, for example stripe.com")


class CompanyLookupTool(BaseTool):
    name: str = "Company lookup"
    description: str = (
        "Everything public about a company from its website domain: registrar, DNS and email "
        "provider, tech stack, and hiring (open roles, salary ranges, recent openings and closings)."
    )
    args_schema: Type[BaseModel] = DomainInput

    def _run(self, domain: str) -> str:
        try:
            return json.dumps(gt.company(domain))
        except GroundTruthError as e:
            return str(e)


class CompanyContactsTool(BaseTool):
    name: str = "Company contacts"
    description: str = (
        "How to reach a company, from its own website: phones, role inboxes such as sales@, "
        "postal address and contact page. Every item names its source page."
    )
    args_schema: Type[BaseModel] = DomainInput

    def _run(self, domain: str) -> str:
        try:
            return json.dumps(gt.contacts(domain))
        except GroundTruthError as e:
            return str(e)


researcher = Agent(
    role="Company researcher",
    goal="Find out what a company does, what it runs on, whether it is hiring, and how to reach it",
    backstory="You check public facts and cite where they came from.",
    tools=[CompanyLookupTool(), CompanyContactsTool()],
)
task = Task(
    description="Look up stripe.com and summarise hiring and tech stack.",
    expected_output="A short summary with the numbers.",
    agent=researcher,
)
print(Crew(agents=[researcher], tasks=[task]).kickoff())
```

The remote MCP endpoint through CrewAI's `MCPServerAdapter`:

```python
# Docs: https://docs.crewai.com/en/mcp/overview
#       https://docs.crewai.com/en/mcp/streamable-http
# pip install 'crewai-tools[mcp]'
from crewai import Agent
from crewai_tools import MCPServerAdapter

server_params = {
    "url": "https://companyfacts.dev/mcp",
    "transport": "streamable-http",
}

with MCPServerAdapter(server_params, "company_lookup", connect_timeout=60) as tools:
    researcher = Agent(
        role="Company researcher",
        goal="Look up companies by domain",
        backstory="You check public facts.",
        tools=tools,
    )
```

With a key, CrewAI's documented way to send a header is the `mcps` field with `MCPServerHTTP`:

```python
# Docs: https://docs.crewai.com/en/mcp/overview  (structured configuration)
from crewai import Agent
from crewai.mcp import MCPServerHTTP

researcher = Agent(
    role="Company researcher",
    goal="Look up companies by domain",
    backstory="You check public facts.",
    mcps=[
        MCPServerHTTP(
            url="https://companyfacts.dev/mcp",
            headers={"Authorization": "Bearer your-key"},
            streamable=True,
        ),
    ],
)
```

### LlamaIndex

A `FunctionTool`:

```python
# Docs: https://developers.llamaindex.ai/python/framework/module_guides/deploying/agents/tools/
import json
from llama_index.core.agent.workflow import FunctionAgent
from llama_index.core.tools import FunctionTool
from groundtruth_company import Client, GroundTruthError

gt = Client()  # or Client(api_key="...")


def company_lookup(domain: str) -> str:
    """Everything public about a company from its website domain: registrar, DNS and email
    provider, tech stack, and hiring (open roles, salary ranges, recent openings and closings)."""
    try:
        return json.dumps(gt.company(domain))
    except GroundTruthError as e:
        return str(e)


def company_contacts(domain: str) -> str:
    """How to reach a company, from its own website: phones, role inboxes such as sales@,
    postal address and contact page. Every item names its source page."""
    try:
        return json.dumps(gt.contacts(domain))
    except GroundTruthError as e:
        return str(e)


tools = [FunctionTool.from_defaults(company_lookup), FunctionTool.from_defaults(company_contacts)]
agent = FunctionAgent(llm=llm, tools=tools)   # llm: any LlamaIndex LLM you have configured
response = await agent.run("Is stripe.com hiring? What do they run on?")
```

The remote MCP endpoint with `McpToolSpec`:

```python
# Docs: https://developers.llamaindex.ai/python/framework-api-reference/tools/mcp/
# pip install llama-index-tools-mcp
from llama_index.core.agent.workflow import FunctionAgent
from llama_index.tools.mcp import BasicMCPClient, McpToolSpec

client = BasicMCPClient(
    "https://companyfacts.dev/mcp",
    # headers={"Authorization": "Bearer your-key"},   # optional, lifts the free tier
)
tool_spec = McpToolSpec(client=client, allowed_tools=["company_lookup"])
tools = await tool_spec.to_tool_list_async()
agent = FunctionAgent(llm=llm, tools=tools)
```

## How these examples were checked

Import paths were read from the framework docs linked above on 2026-10-30. The four MCP connection snippets without a key (`langchain.mcp`, `langchain-mcp-adapters`, CrewAI `MCPServerAdapter`, LlamaIndex `McpToolSpec`) were each run against the live endpoint and listed the `company_lookup` tool. The key variants (headers, `auth=`, `MCPServerHTTP`) follow the docs but were not run with a real key. The agent wiring (model names, `llm`) is yours to fill in.

## Other things this API does

The same host serves job-board data (open jobs at thousands of companies), web page reading, email checks, domain records and more. The full list: https://companyfacts.dev/llms.txt

MIT licensed.
