Metadata-Version: 2.4
Name: enigma-api-client
Version: 0.12.0
Summary: Python SDK for the Enigma GraphQL API: search business data with GraphQL queries or questions in plain English
Author-email: Enigma Technologies <info@enigma.com>
License-Expression: Apache-2.0
Project-URL: Homepage, https://enigma.com
Project-URL: GraphQL API, https://documentation.enigma.com/guides/graphql
Keywords: enigma,graphql,business data,company data,kyb,sdk
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Intended Audience :: Developers
Classifier: Framework :: AsyncIO
Classifier: Typing :: Typed
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx==0.28.1
Requires-Dist: pyyaml==6.0.3
Requires-Dist: pydantic<3.0,>=2.0
Requires-Dist: graphql-core==3.2.12
Provides-Extra: anthropic
Requires-Dist: claude_agent_sdk==0.2.121; extra == "anthropic"
Provides-Extra: bedrock
Requires-Dist: botocore>=1.34.0; extra == "bedrock"
Provides-Extra: all
Requires-Dist: claude_agent_sdk==0.2.121; extra == "all"
Requires-Dist: botocore>=1.34.0; extra == "all"
Dynamic: license-file

# Enigma API Client

The Enigma Python SDK, [`enigma-api-client`](https://pypi.org/project/enigma-api-client/), sends GraphQL queries to the [Enigma GraphQL API](https://documentation.enigma.com/guides/graphql) and turns questions in plain English into those queries. This page is the SDK reference.

## Prerequisites

- An Enigma API key. Find yours in the [Enigma Console](https://console.enigma.com/settings/api-keys) under **Settings > Account > API keys**.
- Python 3.11 or later.
- For a local strategy, which generates queries on your machine: an Anthropic or OpenRouter API key, or AWS credentials with access to Claude on Amazon Bedrock.

The examples read your Enigma API key from the `ENIGMA_API_KEY` environment variable:

```bash
export ENIGMA_API_KEY="your-api-key"
```

## Install

```bash
pip install enigma-api-client
```

Optional extras add dependencies for specific features:

| Extra | Installs | Needed for |
|---|---|---|
| `enigma-api-client[bedrock]` | `botocore` | `BedrockProvider`, Claude on Amazon Bedrock as the LLM of `TemplateStrategy` or `GenerativeStrategy` |
| `enigma-api-client[anthropic]` | `claude_agent_sdk` | `AgentStrategy` |
| `enigma-api-client[all]` | Both of the above | `BedrockProvider` and `AgentStrategy` |

## Quick Start

### Ask a Question

```python
import asyncio
import os

from enigma_api_client import Client, ClientConfig


async def main():
    config = ClientConfig(enigma_api_key=os.environ["ENIGMA_API_KEY"])
    async with Client(config) as client:
        response = await client.search(prompt="Tell me about Chipotle")
        if not response.is_success():
            raise RuntimeError(f"HTTP {response.status_code}: {response.errors}")
        brand = response.data["search"][0]
        print(brand["names"]["edges"][0]["node"]["name"])  # CHIPOTLE MEXICAN GRILL
        print(brand["__url"])  # the brand's profile in the Enigma Console


asyncio.run(main())
```

The SDK turns the question into a GraphQL query, runs it, and returns the result. By default, Enigma's hosted query service writes the query, so your Enigma API key is all you need.

### Send a GraphQL Query

```python
import asyncio
import os

from enigma_api_client import Client, ClientConfig

QUERY = """
query Brand($name: String!) {
  search(searchInput: { name: $name, entityType: BRAND, conditions: { limit: 1 } }) {
    ... on Brand {
      names(first: 1) { edges { node { name } } }
      websites(first: 1) { edges { node { website } } }
    }
  }
}
"""


async def main():
    config = ClientConfig(enigma_api_key=os.environ["ENIGMA_API_KEY"])
    async with Client(config) as client:
        response = await client.search(query=QUERY, variables={"name": "Chipotle"})
        if not response.is_success():
            raise RuntimeError(f"HTTP {response.status_code}: {response.errors}")
        brand = response.data["search"][0]
        print(brand["names"]["edges"][0]["node"]["name"])  # CHIPOTLE MEXICAN GRILL
        print(brand["websites"]["edges"][0]["node"]["website"])  # https://www.chipotle.com


asyncio.run(main())
```

A name can match many entities, so `conditions: { limit: 1 }` keeps only the first. The [GraphQL guide](https://documentation.enigma.com/guides/graphql) and the [schema reference](https://documentation.enigma.com/reference/graphql_api) describe the queries you can write, and Write GraphQL by Hand below lists the mistakes to avoid.

Later snippets that set `config` replace the `config = ...` line in these programs.

## Configure the Client

`ClientConfig` takes these fields:

| Field | Default | Meaning |
|---|---|---|
| `enigma_api_key` | Required | Your Enigma API key |
| `query_strategy` | `ServiceStrategy` | The strategy that turns questions into queries (see Choose a Strategy) |
| `tier` | `AccessTier.REDISTRIBUTION` | Your key's access tier, which local strategies write queries for (see Access Tiers) |
| `url` | `https://api.enigma.com/graphql` | The GraphQL endpoint |
| `auto_inject_id_and_typename` | `True` | Adds `id` and `__typename` to the inline fragments and connection nodes a query selects (see The Shape of `data`) |
| `auto_inject_page_info` | `False` | Adds `pageInfo { hasNextPage endCursor }` to every connection a query selects that doesn't already select `pageInfo` (see Paginate) |
| `customize_headers_callback` | `None` | A function that takes the default request headers and returns headers to add or replace. A header returned with an empty value is removed. Searches use it; the schema fetch and the hosted query service don't. |

### Client Lifecycle

- Create one `Client` and reuse it for all your requests. It keeps connections open until you close it, either with `async with Client(config) as client:` or with `await client.aclose()`. Closing the client also closes its strategy.
- All methods are asynchronous. In a Jupyter notebook, which already runs an event loop, replace `asyncio.run(main())` with `await main()`. Start Jupyter from a shell where `ENIGMA_API_KEY` is set.
- Use a client from one event loop. Don't share it across threads that each run their own loop.

### Run Searches Concurrently

One client can run many searches at once:

```python
import asyncio
import os

from enigma_api_client import Client, ClientConfig


async def main():
    config = ClientConfig(enigma_api_key=os.environ["ENIGMA_API_KEY"])
    async with Client(config) as client:
        prompts = ["Tell me about Chipotle", "Tell me about Sweetgreen", "Tell me about Shake Shack"]
        responses = await asyncio.gather(*(client.search(prompt=p) for p in prompts), return_exceptions=True)
        for prompt, response in zip(prompts, responses):
            if isinstance(response, Exception):
                print(prompt, "failed:", type(response).__name__)
            else:
                print(prompt, response.status_code)


asyncio.run(main())
```

`return_exceptions=True` keeps one failed search from hiding the others' results. Your plan's [rate limits](https://documentation.enigma.com/resources/rate-limits) cap requests per second, bursts and requests per day. An `asyncio.Semaphore` caps how many calls run at once, which keeps bursts down but doesn't enforce a rate.

### Timeouts

The client waits up to 60 seconds for the API, and `ServiceStrategy` waits up to 30 seconds for the hosted query service. A timeout raises `httpx.TimeoutException`.

## Read the Response

`client.search()` returns a `SearchResponse`:

| Field | Holds |
|---|---|
| `status_code` | The HTTP status of the API's answer |
| `data` | The GraphQL `data` object, or `None` |
| `errors` | The GraphQL `errors` list, or `None` |
| `extensions` | The GraphQL `extensions` object, or `None` |
| `unavailable_data` | For a question: the parts Enigma cannot answer (see Unavailable Data) |
| `raw_body` | The response body as text |
| `headers` | The HTTP response headers, looked up case-insensitively, such as `Retry-After` |

`response.is_success()` is true for a 2xx answer with no errors, with two exceptions:

- HTTP 206. The API answers 206 when values it computes in the background, such as some counts, are not ready yet, and leaves them null. The rest of `data` is filled in, so a 206 answer is still usable.
- An answer whose `extensions` report a background task that failed, was cancelled or ran out of credits.

The client asks the API to wait up to 25 seconds for background values before it answers, unless `ClientConfig.url` sets `max_background_task_wait` or `max_call_duration`.

### The Shape of `data`

- `search` returns a list of entities. `__typename` says what each one is: `Brand`, `LegalEntity`, `OperatingLocation`, `Person` or `Address`.
- A question that names several businesses, people or street addresses gets one search for each, answered under `search`, `search2` and `search3`. A person named at a company, or a street address given for a business, shares that business's search. `GenerativeStrategy` and `AgentStrategy` have an LLM write the query and its keys, so with them read every key of `data`. A template query's variables are numbered like its keys: `searchInput`, `searchInput2` and `searchInput3`, and likewise any other variable, such as `locConditions2`.
- In a template query (`ServiceStrategy`'s default, or `TemplateStrategy`), the places a question lists are read this way:
  - A lookup of one location of a business ("the Starbucks at 1 Main St") or of an address gets one search for each place in the lists it gives (several streets, cities or states, and in an address lookup several units or ZIP codes), its lists paired by position, so each city or state there pairs with the business or street in the same position.
  - Elsewhere, every place the question lists applies to each of several businesses or people, so "Starbucks locations in Boston and Dunkin locations in Chicago" searches each brand in both cities. To give each its own place, say "respectively" ("Starbucks and Dunkin locations in Boston and Chicago, respectively") or ask about them separately. Cities and states are read separately. When the question gives a city for some of them and none for another ("Starbucks in Boston, Dunkin, and Peet's in Chicago"), each keeps its own city and the one with none isn't filtered by city; a list of states works the same way.
  - A brand's locations and counts are filtered by city and state. A legal entity lookup lists each business's registrations in the states it gets, under `registrationsInState` in each of its `registeredEntities`: every state the question names for all of them, or each one's own state when the question says "respectively". Brand profiles, person searches, and officer, contact, watchlist, risk and card transaction lookups don't filter by place.
  - When several businesses or people, or the streets of an address lookup, pair with places by position, a list of cities, states, units or ZIP codes longer than theirs leaves a place with none beside it, and the question is refused. Outside a count question, a street left over beside them gets an address lookup of its own while address lookups are on (see Look Up an Address); with them off, the question is refused.
- A count question gets `aggregate`, an object of counts such as `locationsCount` and `brandsCount`. A count of a brand's locations depends on how the query is written. From a template (`ServiceStrategy`'s default, or `TemplateStrategy`) it comes as `locationsCount` on the brand in `search`, one search per brand (`search`, `search2` and so on). From `GenerativeStrategy`, or `ServiceStrategy(query_strategy="generative")`, it comes as `locationsCount` in `aggregate`, one per brand (`aggregate`, `aggregate2` and so on).
- A `Brand` is a business as its customers know it, such as Chipotle. A `LegalEntity` is a company as a government registers it, such as Chipotle Mexican Grill, Inc. An `OperatingLocation` is one place where a brand does business, such as a store. [Understanding Enigma Data](https://documentation.enigma.com/getting_started/enigma_data) describes them in full.
- Related data comes as connections: `{"edges": [{"node": {...}}, ...]}`. Each connection returns one page (see Paginate).

The client adds fields to what you asked for:

- `id` and `__typename` on the inline fragments (`... on Brand { ... }`) and connection nodes the query selects. The client skips an inline fragment that carries a directive, and a connection node when the node or its `edges` carries one. It also skips an entity selected only through a named fragment at the top of `search`, as in `search(...) { ...BrandFields }`. Select `id` and `__typename` yourself in a skipped selection to get `__url`.
- `__url` on each brand, legal entity and operating location that has an `id` and a `__typename`: a link to its profile in the Enigma Console. A person's `__url` links to the brand it appears under, or to Enigma's website when it appears under none.

To send your query exactly as written, set `auto_inject_id_and_typename=False`. A brand, legal entity or operating location then gets `__url` only when your query selects its `id` and `__typename`, and a person only when your query selects its `__typename`. A few connection nodes have no `id`, such as the `MediaContent` nodes of `isMentionedInMediaContents`. A query that selects them fails unless you set `auto_inject_id_and_typename=False`.

The GraphQL query in the quick start returns:

```json
{
  "search": [
    {
      "id": "825ce859-b742-49cb-9787-b9b32f674f25",
      "__typename": "Brand",
      "names": {"edges": [{"node": {"id": "000000d0-0003-cd02-0000-0a8000000000", "__typename": "BrandName", "name": "CHIPOTLE MEXICAN GRILL"}}]},
      "websites": {"edges": [{"node": {"id": "0000002a-0010-60fa-0000-0a8000000000", "__typename": "Website", "website": "https://www.chipotle.com"}}]},
      "__url": "https://console.enigma.com/profile/brand/825ce859-b742-49cb-9787-b9b32f674f25"
    }
  ]
}
```

## Generate Queries from Questions

A question passed as `client.search(prompt=...)` must name what it is about. You can ask:

- About one business or location by name or website, or one person by name: "Tell me about Chipotle", "Who are the officers of Sweetgreen, Inc.?"
- What is at a street address: "What is at 1600 Pennsylvania Ave NW, Washington DC?" (see Look Up an Address)
- For an entity's government records: "Show business licenses held by Walgreens" (see Get Government Records)
- How many businesses of a category are in a place: "How many coffee shops are in Denver?"
- About up to three businesses or people at once: "Compare Starbucks and Dunkin"

Each business or street address a question names, and each person named apart from a business, gets its own search, all in one query. If any one of them is refused, the whole question is refused. A query runs at most three searches or counts, besides the government-record searches the SDK adds beside them (see Get Government Records), so a question that needs more, such as one naming more than three businesses, is refused. The limit counts searches, not names: a template lookup of one location of a business or of an address gives each place it lists its own search, so "the Starbucks at 1 Main St, 2 Elm St, 3 Oak St and 4 Pine St in Boston" is refused. Listing businesses by category ("List coffee shops in Denver") is not supported. Data Enigma doesn't serve, such as lawsuits, is left out and listed in `unavailable_data`.

### Choose a Strategy

A strategy turns each question into a GraphQL query. The default runs on Enigma's servers. The other three are local strategies: they run on your machine and call an LLM with your own account.

| Strategy | How it writes the query | What you need | Speed |
|---|---|---|---|
| `ServiceStrategy` (default) | On Enigma's hosted query service, an LLM picks one of Enigma's tested query templates and fills it in | Your Enigma API key | Fast |
| `TemplateStrategy` | An LLM picks one of Enigma's tested query templates and fills in the names and places | An LLM API key | Fast |
| `GenerativeStrategy` | An LLM writes the whole query; the SDK checks it against the API schema and repairs it | An LLM API key | Slower |
| `AgentStrategy` | A Claude agent on Amazon Bedrock reads Enigma's query reference and writes the query | AWS credentials with Bedrock access, and `enigma-api-client[anthropic]` | Slowest, often tens of seconds |

- **Start with the default.** It needs nothing beyond your Enigma API key.
- **When the default can't answer a question,** `ServiceStrategy(query_strategy="generative", enigma_api_key=...)` has the hosted service write the whole query instead of picking a template.
- **To generate queries with your own LLM account,** use `TemplateStrategy`. Use `GenerativeStrategy` for questions the templates don't cover, and review the queries it writes.
- **For a pipeline that asks the same kind of question many times,** generate the query once, review it, and run it with your own variables (see the section See and Reuse a Generated Query).

To use a strategy other than the default, pass it to `ClientConfig`:

```python
from enigma_api_client.strategies import TemplateStrategy

config = ClientConfig(
    enigma_api_key=os.environ["ENIGMA_API_KEY"],
    query_strategy=TemplateStrategy(),
)
```

### ServiceStrategy

`ServiceStrategy` sends your question to Enigma's hosted query service, authenticated with your Enigma API key, and returns the query the service writes. `client.search` then runs it. The service reads your access tier from the key.

The service picks a template by default, as `TemplateStrategy` does. To have it write the whole query instead, create the strategy yourself:

```python
from enigma_api_client.strategies import ServiceStrategy

strategy = ServiceStrategy(query_strategy="generative", enigma_api_key=os.environ["ENIGMA_API_KEY"])
config = ClientConfig(enigma_api_key=os.environ["ENIGMA_API_KEY"], query_strategy=strategy)
```

A `ServiceStrategy` you create doesn't take the key from `ClientConfig`: pass `enigma_api_key=`, or it reads `ENIGMA_API_KEY`.

### TemplateStrategy

An LLM reads the question, picks one of Enigma's query templates, and extracts the names, places and other values the template needs. The SDK then builds the GraphQL from the template, so the query's fields always come from tested templates.

The templates cover brand profiles, brand lookups by website, a brand's locations, a location's profile, person searches, corporate officers, legal entity verification, contacts, watchlist screening, risk assessments, counts by category and place, counts of a brand's locations, and address lookups. With `tier=AccessTier.EXTERNAL`, they also cover card transactions.

A lookup returns a core set of fields plus only the data the question asks for: industry, website, phone, officers, bankruptcies, watchlist entries, TINs, registration details, locations, affiliates, alternate names, address, reviews or headcount.

- A compliance question adds officers, TINs, watchlist entries, bankruptcies and registration details. A question counts as one when it mentions KYB, KYC, AML, compliance, due diligence, a background check or vetting, or asks to vet or screen the entity.
- A question asking for everything about an entity adds all of them.
- With several businesses or people, the data the question asks for is added to each one's search.
- TINs and watchlist entries are left out for `AccessTier.REDISTRIBUTION`, the default tier (see Access Tiers).

A question no template fits raises `EnigmaClientException`: `UNSUPPORTED_DATA_REQUESTED` for a category listing, and `INVALID_QUERY` for a question that is not about a business, location, person or address.

| Argument | Default | Meaning |
|---|---|---|
| `anthropic_api_key` | `ANTHROPIC_API_KEY` | The Anthropic API key, when `llm_provider` is not set |
| `model` | `claude-haiku-4-5-20251001` | The Anthropic model, when `llm_provider` is not set |
| `llm_provider` | `None` | Another LLM provider (see LLM Providers) |
| `shape_cache` | On, unless `ENIGMA_SHAPE_CACHE_ENABLED` turns it off | The query shape cache |
| `shape_cache_max_shapes` | 8192 | The most shapes the cache keeps |
| `address_search` | `ENIGMA_ADDRESS_SEARCH`, else `"auto"` | Whether address lookups are offered (see Look Up an Address) |

### GenerativeStrategy

One LLM call writes the whole query, guided by an excerpt of Enigma's GraphQL reference, example query patterns and rules, which tell it to write its values into the query text. The SDK then checks the query against the API schema and the rules the API enforces at execution. It fixes what it can without a model call and otherwise makes one more LLM call to repair the query. This handles questions the templates don't cover. The LLM can still choose fields that don't answer the question, so review the queries it writes before you rely on them.

`GenerativeStrategy` refuses a query with `UNSUPPORTED_DATA_REQUESTED` instead of running it when any of its searches is one of the first four of these, or when every search or count in it is the last:

- A person search by surname alone.
- An EIN or filing number searched as a name.
- A wildcard name pattern.
- A street address searched as a name.
- A category count written for a question that asks for a list.

It takes the same arguments as `TemplateStrategy`.

### AgentStrategy

A Claude agent, built with the Claude Agent SDK, reads Enigma's GraphQL reference, examples and field notes, and writes the query. The SDK checks the agent's answer against the API schema and applies the same checks as `GenerativeStrategy`, fixing what it can without a model call. It suits complex questions best and is the slowest strategy.

```bash
pip install "enigma-api-client[anthropic]"
```

```python
from enigma_api_client.strategies import AgentStrategy

config = ClientConfig(enigma_api_key=os.environ["ENIGMA_API_KEY"], query_strategy=AgentStrategy())
```

The agent runs on Amazon Bedrock only:

- Credentials come from the standard AWS credential chain.
- The identity needs `bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream` on the inference profile and on the foundation model in every region the profile routes to.
- The default model is the US inference profile `us.anthropic.claude-haiku-4-5-20251001-v1:0`. The region is `region=`, else `AWS_REGION` or `AWS_DEFAULT_REGION`, else `us-east-1`. Pass `model=` and `region=` to change them, and keep the region inside the profile's geography.
- `max_turns` (default 8) and `max_budget_usd` (default 0.25) bound each call.

The agent runs the Claude Code CLI bundled with `claude_agent_sdk` as a subprocess:

- Its nonessential network traffic is turned off.
- Its only tool reads files inside the query reference bundled with the SDK. It has no shell, file writes, web access or file attachments.
- Every environment variable it inherits is blanked, except process basics, TLS and proxy settings, and the AWS credential chain.
- It keeps no session transcript on disk.

The agent writes values into the query itself, so the query it returns has no variables. When its answer fails the schema check, `AgentStrategy` raises `UnrepairedQueryError`.

### See and Reuse a Generated Query

`strategy.generate_query(prompt)` returns the query without running it:

```python
import asyncio
import os

from enigma_api_client import Client, ClientConfig
from enigma_api_client.strategies import ServiceStrategy


async def main():
    key = os.environ["ENIGMA_API_KEY"]
    strategy = ServiceStrategy(enigma_api_key=key)
    async with Client(ClientConfig(enigma_api_key=key, query_strategy=strategy)) as client:
        generated = await strategy.generate_query("Tell me about Chipotle")
        print(generated.query)
        print(generated.variables)  # {'searchInput': {'name': 'Chipotle', 'entityType': 'BRAND', 'conditions': {'limit': 1}}}

        variables = {"searchInput": {**generated.variables["searchInput"], "name": "Sweetgreen"}}
        response = await client.search(query=generated.query, variables=variables)
        print(response.data["search"][0]["names"]["edges"][0]["node"]["name"])  # SWEETGREEN


asyncio.run(main())
```

- Check a query before you reuse it with other variables. Any strategy can write some values into the query text, where your variables don't replace them. For example, when a question names a state, a legal entity lookup writes that state into the query, and a query that selects government records writes the searched name into its archive searches. `GenerativeStrategy` is told to write every value into the query text, and `AgentStrategy` always does.
- Call `generate_query` inside the `async with` block. A local strategy checks its query against the API schema, which the `Client` fetches with your key.
- Called directly, a local strategy's `generate_query` writes for `AccessTier.REDISTRIBUTION` unless you pass your tier, as in `generate_query(prompt, tier=AccessTier.EXTERNAL)`. `client.search` passes `ClientConfig.tier` for you. `ServiceStrategy` always writes for your key's tier.

`generate_query` returns a `GenerateQueryResponse`:

| Field | Holds |
|---|---|
| `query`, `variables` | The GraphQL query and its variables |
| `unavailable_data` | The parts of the question Enigma cannot answer |
| `deterministic_repair` | When the SDK fixed the query without a model call, the kinds of fixes, comma separated |
| `cache_rule` | `"shape"` when the query shape cache answered without an LLM call; other values report what the cache learned from the call |
| `records` | For a question asking for government records, from a local strategy: `selected` when the SDK added record fields, or why it added none (`no_schema`, `no_entity`, `already_selected`, `invalid`) |

### Access Tiers

The fields you can read depend on your API key's access tier:

- `AccessTier.REDISTRIBUTION` is the narrowest tier.
- `AccessTier.EXTERNAL` adds fields such as TINs, watchlist entries and card transaction data.

The hosted service reads the tier from your key. A local strategy writes queries for the tier in `ClientConfig.tier`, which defaults to `AccessTier.REDISTRIBUTION`. Set it to your key's tier:

```python
from enigma_api_client import AccessTier
from enigma_api_client.strategies import TemplateStrategy

config = ClientConfig(
    enigma_api_key=os.environ["ENIGMA_API_KEY"],
    query_strategy=TemplateStrategy(),
    tier=AccessTier.EXTERNAL,
)
```

If you keep the default `tier`, a local strategy writes queries that leave out fields a broader key could read. If you don't know your key's tier, ask [Enigma support](mailto:support@enigma.com).

- A local strategy removes from the query each field your tier is not served, and lists it in `unavailable_data` with the reason `contractual`. `enigma_api_client.restricted_fields_for(tier)` returns those fields.
- `enigma_api_client.gate_blocked_fields(tier)` returns those fields plus, for `AccessTier.REDISTRIBUTION`, fields the API serves but a redistribution contract forbids. A local strategy doesn't remove those extra fields, and the API can return them to your own queries too, so use `gate_blocked_fields` to filter output you pass on.
- The SDK fetches the API schema for the `tier` of the first `Client` you create in a process, with that client's key, and checks generated queries against it. Set `tier` before you create your first `Client`. In a notebook, restart the kernel after you change it.
- A later `Client` with a different `tier` gets no schema. For a local strategy on that client:
  - Generated queries aren't checked against the schema.
  - Government records are left out.
  - An address-only question is refused unless address lookups are on (`address_search=True` or `ENIGMA_ADDRESS_SEARCH=on`).
  - `AgentStrategy` raises `PROVIDER_ERROR`.

To serve several tiers in one process, register a function that takes an `AccessTier` and returns that tier's schema, before you create the first `Client`. The SDK calls it either on your event loop's thread, blocking every search on that loop until it returns, or from a background thread with no context variables set. So it must be synchronous and thread-safe, return in bounded time and not read context variables:

```python
import os

from enigma_api_client import AccessTier, validation

KEYS = {
    AccessTier.EXTERNAL: os.environ["ENIGMA_EXTERNAL_KEY"],
    AccessTier.REDISTRIBUTION: os.environ["ENIGMA_API_KEY"],
}


def introspector(tier):
    key = KEYS.get(tier)
    if key is None:
        return None
    try:
        return validation.introspect_schema("https://api.enigma.com/graphql", key)
    except Exception:
        return None


validation.set_schema_introspector(introspector)  # before you create the first Client
```

Each `Client` then gets the schema for its own `tier`. Return `None` when a fetch fails, as above, so a failed fetch doesn't raise from `client.search`.

The first time a tier's schema is used after it is five minutes old, the SDK fetches it again in the background. Pass `refresh_seconds=` to `set_schema_introspector` to change that interval, or `refresh_seconds=None` to turn the refresh off.

### LLM Providers

`TemplateStrategy` and `GenerativeStrategy` call Anthropic's API by default, with `anthropic_api_key=` or the `ANTHROPIC_API_KEY` environment variable; their `model=` argument applies only to that default. Pass `llm_provider=` to use another provider:

```python
from enigma_api_client.strategies import (
    AnthropicProvider, BedrockProvider, GenerativeStrategy, OpenRouterProvider, TemplateStrategy,
)

# Anthropic's API (ANTHROPIC_API_KEY)
TemplateStrategy(llm_provider=AnthropicProvider(model="claude-haiku-4-5-20251001"))

# OpenRouter (OPENROUTER_API_KEY). provider_routing is sent as OpenRouter's "provider"
# object and reasoning as its "reasoning" object; omit either to keep OpenRouter's default.
GenerativeStrategy(llm_provider=OpenRouterProvider(
    model="anthropic/claude-haiku-4.5",
    provider_routing={"order": ["anthropic"], "allow_fallbacks": False},
    reasoning={"enabled": False},
))

# Claude on Amazon Bedrock (pip install "enigma-api-client[bedrock]"), signed with your AWS credentials.
# The default model is a US inference profile; pass model= outside US regions.
TemplateStrategy(llm_provider=BedrockProvider(region="us-east-1"))
```

To use another LLM, implement the `LlmProvider` protocol: `async def complete(system, user, max_tokens) -> Completion`. Return `Completion(text, truncated)`, where `truncated` is true when the model hit its output limit. For an HTTP failure, raise `ProviderHTTPError(status_code, message)`: 408, 429 and 5xx are reported as `TRANSIENT_PROVIDER_ERROR`, any other HTTP status as `PROVIDER_ERROR`, and a value that is not an HTTP status as `TRANSIENT_PROVIDER_ERROR`. Pass the call's token counts to `Completion` as `input_tokens=`, `output_tokens=`, `cache_read_tokens=` and `cache_write_tokens=` so that Token Usage includes them. Leave cache reads and writes out of `input_tokens`; they have their own fields. `LlmProvider`, `Completion` and `ProviderHTTPError` import from `enigma_api_client.strategies`. Raise `ValueError` for a missing key or bad configuration (reported as `GENERATE_QUERY_CALLBACK_NOT_CONFIGURED`) and `RuntimeError` for any other provider failure (reported as `TRANSIENT_PROVIDER_ERROR`). Any other exception propagates unchanged.

### Query Shape Cache

`TemplateStrategy` and `GenerativeStrategy` keep an in-memory cache of query shapes learned from successful generations. A shape is a generated query with its names and places left as blanks. Once a shape has been confirmed across several different entities, a matching question is answered from the cache without an LLM call. The cache is on by default and is shared by every caller of the same strategy instance at the same tier. Turn it off with `shape_cache=False` or by setting `ENIGMA_SHAPE_CACHE_ENABLED=0`.

### Token Usage

After `client.search(prompt=...)` or `generate_query` returns or raises, `enigma_api_client.strategies.GENERATION_USAGE.get()` returns that call's LLM token counts as an `LlmUsage`: `completions`, `input_tokens`, `cache_read_tokens`, `cache_write_tokens` and `output_tokens`, plus `unmetered_completions`, the completions whose provider left out the input or the output token count, so the sums miss their tokens. Read it in the same task that awaited the call. `asyncio.gather` runs each call in a task of its own, so read the usage inside the coroutine you pass to it. `ServiceStrategy` reports zero usage, and `AgentStrategy` reports its run's totals.

### Write Your Own Strategy

Subclass `QueryStrategy`, implement `generate_query` and `aclose`, and set `GENERATION_USAGE` to a fresh `LlmUsage()` at the start of `generate_query`. This strategy treats the whole question as a brand name:

```python
import asyncio
import os

from enigma_api_client import DEFAULT_TIER, Client, ClientConfig, QueryStrategy
from enigma_api_client.strategies import GENERATION_USAGE, LlmUsage
from enigma_api_client.strategies.base import GenerateQueryResponse

QUERY = """
query Brand($name: String!) {
  search(searchInput: { name: $name, entityType: BRAND, conditions: { limit: 1 } }) {
    ... on Brand { names(first: 1) { edges { node { name } } } }
  }
}
"""


class BrandNameStrategy(QueryStrategy):
    async def generate_query(self, prompt, *, tier=DEFAULT_TIER, prior_attempt=None):
        GENERATION_USAGE.set(LlmUsage())
        return GenerateQueryResponse(QUERY, {"name": prompt})

    async def aclose(self):
        pass


async def main():
    config = ClientConfig(enigma_api_key=os.environ["ENIGMA_API_KEY"], query_strategy=BrandNameStrategy())
    async with Client(config) as client:
        response = await client.search(prompt="Chipotle")
        print(response.data["search"][0]["names"]["edges"][0]["node"]["name"])  # CHIPOTLE MEXICAN GRILL


asyncio.run(main())
```

## Handle Errors

```python
import asyncio
import os

from enigma_api_client import Client, ClientConfig, EnigmaClientException


async def main():
    config = ClientConfig(enigma_api_key=os.environ["ENIGMA_API_KEY"])
    async with Client(config) as client:
        try:
            response = await client.search(prompt="List coffee shops in Denver")
        except EnigmaClientException as exc:
            print(exc.error_code, exc)
            return
        if not response.is_success():
            print("HTTP", response.status_code, response.errors)


asyncio.run(main())
```

The example prints the error message, which is useful while you develop. In production, log the exception type and `error_code` instead, because the message can quote values from the query.

### Generation Errors

When the SDK can't generate a query, `client.search(prompt=...)` raises `EnigmaClientException`, whose `error_code` is an `EnigmaClientErrorCode`.

With `ServiceStrategy`, the default, the code is always `INVALID_QUERY`, including when the hosted service fails with HTTP 401, 429 or 5xx. When the service answers with an error status, `exc.status_code` holds it, `exc.headers` holds its response headers, and the message reads `Enigma API returned HTTP <status>:` followed by the start of its answer. Retry when the status is 429 or 5xx, after the number of seconds in `exc.headers.get("Retry-After")` when it gives one. When the service answers with a GraphQL error instead, the message starts `Enigma API GraphQL error:`. For the example above, the message begins `Enigma API returned HTTP 400` and explains that listing businesses by category is not supported.

Local strategies use these codes:

| Code | Meaning |
|---|---|
| `INVALID_QUERY` | The question or the generated query is unusable |
| `UNSUPPORTED_DATA_REQUESTED` | Enigma doesn't serve what the question asks for, or the question needs a query the SDK refuses (see Generate Queries from Questions and GenerativeStrategy) |
| `GENERATE_QUERY_CALLBACK_NOT_CONFIGURED` | An LLM API key is missing, or the provider is misconfigured |
| `TRANSIENT_PROVIDER_ERROR` | The LLM provider timed out, rate limited the call, or failed with a 5xx error; retry with backoff |
| `PROVIDER_ERROR` | Any other LLM provider failure, such as HTTP 401 or 403; retrying won't help. `AgentStrategy` also raises it when the SDK has no API schema for your tier (see Access Tiers); a retry helps only when the schema fetch failed for a moment. |

For a provider failure, `exc.status_code` holds the provider's HTTP status when it reported one.

The SDK never raises `RESTRICTED_DATA_REQUESTED` or `INVALID_VARIABLES`; they exist for applications that enforce their own policies.

Two subclasses of `EnigmaClientException`, both with the code `INVALID_QUERY`, import from `enigma_api_client`:

- `TruncatedOutputError`: the LLM hit its output limit. It happens again with the same model and question, so don't retry with the same model.
- `UnrepairedQueryError`: `GenerativeStrategy` or `AgentStrategy` wrote a query that failed the schema check even after repair. It carries `.draft`, `.errors` and `.tier`, and you can pass it to another `GenerativeStrategy` as `generate_query(prompt, prior_attempt=exc)` to start from that draft. Its message quotes values from the generated query, so log the exception type and `error_code` rather than `str(exc)`.

### Unavailable Data

When a question asks for data Enigma doesn't serve, the rest of the question is still answered, and `response.unavailable_data` lists what was left out, one entry per item with `field`, `reason` and `message` keys. "What is Chipotle's annual revenue?" with a redistribution key returns the brand and this entry:

```python
[{"field": "revenue", "reason": "contractual",
  "message": "Revenue and other card-derived financials are not served due to contractual restrictions."}]
```

`reason` is `contractual` when your tier is not served the data, and `out_of_scope` otherwise, for example when Enigma doesn't have it. `message` says why. "Tell me about Chipotle, including any lawsuits against it" lists `lawsuits` as `out_of_scope`.

### API and Network Errors

- An error status from the API doesn't raise. Check `response.is_success()` and `response.status_code`, and read [response status codes](https://documentation.enigma.com/guides/graphql/response-status-codes). A 402 means a billing problem, such as running out of credits.
- A 429 means you went over a [rate limit](https://documentation.enigma.com/resources/rate-limits). Wait at least the number of seconds in its `Retry-After` header, `response.headers.get("Retry-After")`, before you retry. Stop after a few retries, because a 429 can also mean you have used your daily limit.
- A response body that isn't a JSON object, such as a gateway's error page, comes back with `data` set to `None`, an error in `errors` and the body in `raw_body`, so `is_success()` is false.
- An API key or header value an HTTP header can't carry as written, such as one with a newline or a trailing space, raises `ValueError` naming the header, before the API request is sent.
- Network failures, from the API or the hosted query service, raise `httpx` exceptions such as `httpx.TimeoutException` and `httpx.ConnectError`.

## Paginate

Each connection returns one page. To read every page, select `pageInfo { hasNextPage endCursor }` on the connection and send the same query again with `after:` set to the last `endCursor`:

```python
import asyncio
import os

from enigma_api_client import Client, ClientConfig

QUERY = """
query Locations($name: String!, $cursor: String) {
  search(searchInput: { name: $name, entityType: BRAND, conditions: { limit: 1 } }) {
    ... on Brand {
      operatingLocations(first: 100, after: $cursor) {
        edges { node { addresses(first: 1) { edges { node { fullAddress } } } } }
        pageInfo { hasNextPage endCursor }
      }
    }
  }
}
"""


async def main():
    config = ClientConfig(enigma_api_key=os.environ["ENIGMA_API_KEY"])
    async with Client(config) as client:
        cursor, addresses = None, []
        while True:
            response = await client.search(query=QUERY, variables={"name": "Sweetgreen", "cursor": cursor})
            if not response.is_success():
                raise RuntimeError(f"HTTP {response.status_code}: {response.errors}")
            locations = response.data["search"][0]["operatingLocations"]
            for edge in locations["edges"]:
                for address in edge["node"]["addresses"]["edges"]:
                    addresses.append(address["node"]["fullAddress"])
            if not locations["pageInfo"]["hasNextPage"]:
                break
            cursor = locations["pageInfo"]["endCursor"]
        print(len(addresses), "addresses")


asyncio.run(main())
```

- Start with `cursor` set to `None`.
- Use `hasNextPage` to decide whether more pages exist. A full page doesn't mean there are more.
- Keep the connection's `first` and `conditions` (with its `filter` and `orderBy`) the same on every page, because a cursor is only valid for the arguments it came from.
- `ClientConfig(auto_inject_page_info=True)` adds `pageInfo { hasNextPage endCursor }` to every connection that doesn't already select `pageInfo`, in every query the client sends, including generated ones.
- Generated queries already select page info (for `GenerativeStrategy` and `AgentStrategy`, once the API schema for your tier is cached) on a brand's affiliated brands (`Brand.affiliatedBrands`), a legal entity's parent and subsidiary legal entities (`LegalEntity.legalEntities`) and the government record pages. The template that lists a brand's locations selects it on `Brand.operatingLocations` too. The legal entity lookup selects it on `registrationsInState` when it lists registrations in several states, which share one page.
- To read more pages of a generated query, get it with `generate_query` and add `after: $cursor` to the connection. Then declare `$cursor: String` with the query's other variables, as the example above does. If the query declares no variables yet, add `($cursor: String)` after its name, or begin it with `query ($cursor: String) {` when it has no name.
- `roles` doesn't paginate (see Rules the API Enforces at Execution).

## Look Up an Address

A question about a street address with a house number and no business name searches addresses:

```python
import asyncio
import os

from enigma_api_client import Client, ClientConfig


async def main():
    config = ClientConfig(enigma_api_key=os.environ["ENIGMA_API_KEY"])
    async with Client(config) as client:
        response = await client.search(prompt="What is at 1600 Pennsylvania Ave NW, Washington DC?")
        if not response.is_success():
            raise RuntimeError(f"HTTP {response.status_code}: {response.errors}")
        for address in response.data["search"]:
            locations = address["operatingLocations"]["edges"]
            print(f'{address["fullAddress"]}: {len(locations)} operating locations listed')


asyncio.run(main())
```

The query is an `entityType: ADDRESS` search that returns up to 10 addresses: the building and units in it. When the question names a unit, that unit usually comes first. Each address carries:

- Its address fields, such as `fullAddress`, `city`, `state` and `zip`.
- The first page of the operating locations (with brand names), legal entities and registrations at the address.

A question that asks how many gets an `unavailable_data` entry, `address counts`, saying the query does not count what is at the address.

How questions are read:

- A business name with an address ("the Starbucks at 123 Main St") is looked up as that business's location instead.
- A ZIP code alone, a street with no house number, or a business type on a street ("the barbershop on Elm Street") is refused.
- Before a local strategy returns the query, the SDK cleans the address the LLM extracted:
  - It moves a suite, floor or apartment into `street2` in USPS short form (`STE 1A`, `FL 8`).
  - It drops a unit designator with no number.
  - It removes surrounding prose and a trailing country.
  - It converts state names to postal codes.

Local strategies take an `address_search=` argument: `True`, `False` or `"auto"`. Without it, they read the `ENIGMA_ADDRESS_SEARCH` environment variable (`on`, `off` or `auto`), and use `auto` when neither is set. `auto` turns address lookups on when the API schema for your key supports them. Each strategy instance decides at its first question for each access tier and keeps that answer. If the schema can't be fetched at that moment, address lookups stay off for that instance and tier. With address lookups off, a question that gives only an address is refused.

No strategy selects the `Address` counts `brandViaLegalEntityCount`, `brandViaPersonLegalEntityCount` and `legalEntityViaRegistrationCount`, because they take too long to compute. Leave them out of your own queries too.

## Get Government Records

Enigma's [Gov Archive](https://documentation.enigma.com/guides/gov-archive) holds rows of government and public datasets, such as state UCC filings, business licenses and city food inspections. Each row is a record, and Enigma links most records to the businesses, locations, people and addresses they name. Each dataset carries tags, such as `ucc_lien`, that name the kinds of records it holds. A question that names an entity and asks for its records gets them. You can ask for:

- UCC and lien filings
- Business and occupational licenses, including liquor, contractor, tobacco and cannabis, insurance, money transmitter, firearms and childcare licenses
- Permits and sales tax permits
- Inspections and health grades
- Violations and enforcement actions
- Government contracts, PPP loans and SAM.gov registrations (UEI, CAGE code)
- Form 5500 benefit plans, H-1B and labor certifications, workers' compensation and WARN notices
- SEC filings, 13F holdings and Form 990 filings
- Property records, EPA records and USDOT motor carrier records
- Campaign finance and lobbying
- Medicare and Medicaid providers, and minority-, women- and veteran-owned business certifications

A question that asks for "public records", "government records" or the "gov archive" in general gets records of every kind, unless it also names one of these. The names a question searches by don't count, so a question about a business named "Permit Pros" asks for no permits.

```python
import asyncio
import os

from enigma_api_client import Client, ClientConfig


async def main():
    config = ClientConfig(enigma_api_key=os.environ["ENIGMA_API_KEY"])
    async with Client(config) as client:
        response = await client.search(prompt="Show business licenses held by Walgreens")
        if not response.is_success():
            raise RuntimeError(f"HTTP {response.status_code}: {response.errors}")
        for entity in response.data["search"]:
            print(entity["__typename"], "linked records:", entity.get("recordCount"))
        for key, items in response.data.items():
            if key.startswith(("archiveRecords", "archiveBrandRecords")):
                for item in items or []:
                    if item["__typename"] == "Record":
                        print(key, item["sourceTitle"], item["data"])


asyncio.run(main())
```

The records come in two places: on each entity the search returns, and in archive searches next to `search` in `data`.

### Records on Each Entity

Each entity the search returns gains these fields:

| Field | Holds |
|---|---|
| `recordCount` | The number of records linked to the entity, over every dataset |
| `recordSourceCounts`, `recordTagCounts` | On a returned address: the record count per dataset title and per tag |
| `recordSources` | On a legal entity the search returns: the first three of its `registeredEntities`, each with `recordSourceCounts` and `recordTagCounts`, with page info |
| `matchingRecordCount` | For a question that names record types, when the entity gets a `records` page: the number of its linked records of those types |
| `records` | The first ten linked records when the search is limited to one entity (`conditions: { limit: 1 }`), otherwise the first three, with `pageInfo { hasNextPage endCursor }`; for a question that names record types, only records of those types |

Legal entities the query selects under a returned brand or location get `recordCount` too, and, when the entities get a `records` page, three `records`.

A question asking for records in general gets the `records` page whatever the API's schema. For a question that names record types, the SDK adds the page and `matchingRecordCount` only when the API's schema shows it can filter an entity's linked records quickly; otherwise the entities get neither, and those records come from the archive searches alone. Read the page with `entity.get("records")`.

### Records from Archive Searches

`archiveRecords` searches the archive by the same name, person (first and last name) or street address as the `search` it sits beside. A name search with `entityType: BRAND`, `entityType: OPERATING_LOCATION` or no `entityType` gets two archive searches: `archiveRecords` searches legal entities, and `archiveBrandRecords` searches brands, which finds records that name a brand or trade name. The two can return the same record. When several searches in the query get archive searches, the keys of the second and later ones end in a number, as in `archiveRecords2`. The number counts only the searches that get archive searches, so it need not match the `search` key beside it, and an archive search identical to an earlier one is sent once.

Each archive search returns the entities it matched, with only `__typename` (and, for a person, `__url`), then up to 50 records. It returns a record when the record's data names the searched name or address, whether or not the record is linked to an entity. For a question that names record types, each record carries one of their tags. A question for records in general gets records with any tag. Read the items whose `__typename` is `Record`.

- The archive matches names loosely, so a record can belong to another business or person with a similar name. It also ignores a city or state given without a street, so that namesake can be anywhere. Check the name in the record's `data`.
- A dataset can carry a record type's tag without belonging to it, so check `sourceTitle`.
- Records are not sorted by date.
- A search with no name, person or street address, such as one by website alone, gets no archive search.
- The archive searches miss many records of these types: UCC and lien filings, contractor licenses, PPP loans, government contracts, SAM.gov registrations, workers' compensation, lobbying and Form 990 filings. When the entities get no `records` page, a question for them often returns few or none.
- Set `ENIGMA_ARCHIVE_SEARCH=off` to leave the archive searches out of queries a local strategy writes.

Each record, in either place, carries:

- `sourceTitle`: the dataset's title.
- `sourceUrl`: a link to the dataset, not to the row. It can be null.
- `tags`: the dataset's tags, such as `ucc_lien` or `abc_license`.
- `firstObservedDate`, `lastObservedDate`: when Enigma first and last saw the row. These are usually not filing dates.
- `data`: the row itself, as column names and values. The filing dates are here.

For the example question, the query gains `recordCount` on each entity it returns, and archive searches next to `search`. The `archiveRecords` search, abbreviated:

```graphql
archiveRecords: search(searchInput: { name: "Walgreens", entityType: LEGAL_ENTITY, dataLayer: UNGROUNDED,
                                      conditions: { limit: 50, filter: { IN: ["tags", ["abc_license", "active_occupational_licenses", ...]] } } }) {
  __typename
  ... on Record { sourceTitle sourceUrl tags firstObservedDate lastObservedDate data }
}
```

### Read More of an Entity's Records

When an entity's `records` page has `pageInfo.hasNextPage` set to true, search that entity alone by its id: `searchInput: { id: "<its id>", entityType: <its type>, conditions: { limit: 1 } }`, with `entityType` set to `BRAND`, `LEGAL_ENTITY`, `OPERATING_LOCATION`, `PERSON` or `ADDRESS` to match its `__typename`. Select `records` with the same `first` and `conditions` plus `after: "<endCursor>"`, because the cursor is only valid for those arguments. To get a query to adapt, call `generate_query`. An archive search returns one page, with no cursor.

### Record Types and Their Phrases

The installed package lists the record types a question can ask for, with the phrases that ask for each and the tags that select it:

```python
import json
from importlib.resources import files

path = files("enigma_api_client") / "plugins/enigma-api/skills/enigma-graphql/references/record_topics.json"
for topic in json.loads(path.read_text())["record_topics"]["topics"]:
    print(topic["id"], topic["phrases"][:3])
```

### Records and Unavailable Data

- An `unavailable_data` entry for something the selected records answer in full, such as "liquor licenses", is removed.
- When the query selects record counts but none of the records the question asks for, for example on a search by website alone when the entities get no `records` page, that entry stays and gets the message "This query does not return these records."
- An entry for something the records answer only in part, such as "tax liens" or "lawsuits and violations", gets the message "The returned records cover this only in part." when the query selects those records.

### Records and the API Schema

A local strategy adds records only once it has the API schema for your tier. With address search on `auto`, the default, a strategy fetches the schema before it answers its first question, because it needs the schema to decide on address lookups. A `TemplateStrategy` whose address search you set yourself (`address_search=True` or `False`, or `ENIGMA_ADDRESS_SEARCH` set to `on` or `off`) fetches the schema in the background instead. Until the schema arrives, and for a while after a failed fetch, its questions can come back without records, and `generate_query` reports `records` as `no_schema`.

## Environment Variables

| Variable | Used by | Meaning |
|---|---|---|
| `ENIGMA_API_KEY` | `ServiceStrategy` | The key of a `ServiceStrategy` you create without `enigma_api_key=` (the default strategy uses the `ClientConfig` key) |
| `ANTHROPIC_API_KEY` | `AnthropicProvider` | Anthropic API key |
| `OPENROUTER_API_KEY` | `OpenRouterProvider` | OpenRouter API key |
| `AWS_REGION`, `AWS_DEFAULT_REGION` | `BedrockProvider`, `AgentStrategy` | Region when `region=` is not passed (default `us-east-1`) |
| `ENIGMA_SHAPE_CACHE_ENABLED` | `TemplateStrategy`, `GenerativeStrategy` | `0`, `false`, `no` or `off` turns off the query shape cache |
| `ENIGMA_ADDRESS_SEARCH` | `TemplateStrategy`, `GenerativeStrategy`, `AgentStrategy` | `on`, `off` or `auto` (`1`, `true` and `yes` also mean on, `0`, `false` and `no` off; see Look Up an Address); read when the strategy is created |
| `ENIGMA_ARCHIVE_SEARCH` | `TemplateStrategy`, `GenerativeStrategy`, `AgentStrategy` | `off`, `0`, `false` or `no` leaves the archive searches out of record queries (see Get Government Records); read on each call |

## Logging

The SDK logs through Python's `logging` module, on loggers named `enigma_api_client.*`:

- Lines at INFO and above are fixed messages with counts, statuses, rule names, exception class names, schema field names and file paths. They never contain your questions or the LLM's text.
- DEBUG lines can contain your questions, the LLM's output and the agent's transcript. Don't turn on DEBUG for these loggers where your logs must not hold that data.

## What Leaves Your Machine

- **Searches**: the query and its variables go to the Enigma API with your Enigma API key.
- **`ServiceStrategy`**: your question goes to Enigma's hosted query service with your Enigma API key. The service writes the query with an LLM. It has its own address, which `ClientConfig.url` doesn't change.
- **Local strategies**: your question and Enigma's query instructions go to the LLM provider you configure (Amazon Bedrock for `AgentStrategy`). A repair also sends the draft query and its errors. Search results never go to the LLM.
- **Schema checks**: the SDK fetches the API schema from the Enigma API with the first `Client`'s key, or with the keys your own introspector uses.

The SDK sends nothing else.

## Write GraphQL by Hand

The Enigma schema has hundreds of fields, several of them named differently from what you might guess. A query can also pass schema validation and still be rejected when it runs. This section lists what most often goes wrong.

### Common Mistakes

| Field you might guess | Actual field |
|---|---|
| `status` (of a location) | `operatingStatus` |
| `url` | `website` |
| `description` | `industryDesc` |
| `title` | `jobTitle` |
| `averageRating` | `reviewScoreAvg` |

- **A field that doesn't exist fails the whole request** with HTTP 400 and `Cannot query field`.
- **`search` returns a list.** Select through inline fragments, as in `search(...) { ... on Brand { ... } }`; it has no `edges`.
- **A category in `name` matches business names.** `name: "coffee shop"` returns businesses with names like "Coffee Shop". To count a category, use `aggregate`.
- **A company's officers are under its registrations.** `LegalEntity.persons` is almost always empty for a company. Follow `registeredEntities`, then `registrations`, then `roles`, then `legalEntities`.
- **Connections have no `totalCount`.** Count with an `aggregate` query, or with `count(field: ...)` on an entity.
- **Website content sits under each website.** Select `websiteContents` inside `websites`: `websites(first: 1) { edges { node { websiteContents(first: 1) { edges { node { httpStatusCode } } } } } }`.
- **A brand search for a local franchise returns the whole chain.** Searching `BRAND` for a single franchised hotel returns the parent brand with all its locations. Search `OPERATING_LOCATION` with an address to find one location.

### Rules the API Enforces at Execution

These queries pass schema validation and are rejected when they run. `GenerativeStrategy` and `AgentStrategy` check these rules before they return a query, and the templates follow them.

| Rule | Error |
|---|---|
| `search` needs `name`, `website`, `person`, `phoneNumber`, `id` or `tin`, even when it has a filter. An address alone works only in an `entityType: ADDRESS` search with `address.street1` (a street line with a house number) or `address.id`. | `Search must include name, website, person, or phone_number` |
| `searchInput.prompt` can't be combined with a selection set. To have the API answer a question in plain English, send the question in `searchInput.prompt` with no selection set (see [API search](https://documentation.enigma.com/guides/graphql/api-search)). | `'prompt' cannot be combined with a selection set` |
| `roles` takes `first` and `conditions` only, although the schema lists `last`, `after` and `before`. Fetch roles as one page. | `LegalEntity.roles does not currently support last, after or before` (the error names the parent type) |
| A `PERSON` search finds people by `person: { firstName, lastName }`, `id` or `tin`, not by `name`. | `Person search must include an identifier or first, middle or last name` |
| A filter's `AND`, `OR` and `NOT` take a list of clauses, and `NOT` takes exactly one. | Rejected |
| A filter clause has exactly one operator. Combine several clauses in one `AND` list. | Rejected |
| `IS_NULL` and `IS_NOT_NULL` test a column. To test for related records on a connection path such as `registrations`, use `HAS` or `NOT: [{ HAS: [...] }]`. | Rejected |
| An `aggregate` filter on `brands.industries.industryCode` needs `{ EQ: ["brands.industries.industryType", "naics_2022_code"] }` in the same `AND`. | `Joins aren't allowed for filter` |

## Upgrade Notes

Each section lists what changed after the version it names. Read every section that names your current version or a later one.

### Upgrading from 0.11.0

With `Client`'s default strategy the hosted service builds the query, so the `TemplateStrategy` and `GenerativeStrategy` changes below reach you when the service adopts the release, whatever SDK version you run.

- `TemplateStrategy` answers a question that names several businesses, people or street addresses with one search for each in the same query, answered under `search`, `search2` and `search3`, where it answered one of them or refused the question. `GenerativeStrategy` likewise searches each brand a question names, keyed `search`, `search2` and `search3`, where it usually searched the first brand only and otherwise keyed the searches with other aliases such as `a` and `b`. Code that reads only `data["search"]` gets the first; read the other keys for the rest.
- A generated query with more than three root fields, not counting the government-record searches the SDK adds beside them, is refused: a local strategy raises `UNSUPPORTED_DATA_REQUESTED`, and `ServiceStrategy` raises `INVALID_QUERY` once the hosted service adopts the release.
- `GenerativeStrategy` counts each brand a how-many question names, under `aggregate`, `aggregate2` and so on, where it counted the first brand only.
- `GenerativeStrategy` and `AgentStrategy` refuse a query when any one of its searches is a shape they refuse (a person search by surname alone, an EIN searched as a name, a wildcard name pattern, or a street address searched as a name), where they served it when another root field beside it was not such a search.
- Templates return a brand's affiliated brands 50 to a page, up from 10, and `GenerativeStrategy` asks its LLM for the same page size.
- A legal entity lookup lists a business's registrations in each state it gets (see The Shape of `data`), where it listed none when it got several.
- In a lookup of one location of a business ("the Starbucks at 1 Main St") or of an address, `TemplateStrategy` searches each place the question lists (several streets, cities or states, and units and ZIP codes in an address lookup), where it sent the list in a single field, dropped it, or refused the question. A question about a brand's locations in several cities still gets one search covering them all. For several businesses or people, every city, and every state, applies to each unless the question says "respectively" or gives one of them no city (or no state), except in a lookup of one location of a business, where each city or state pairs with the business in the same position. When several businesses or people, or the streets of an address lookup, pair with places by position, a list of cities, states, units or ZIP codes longer than theirs leaves a place with none beside it, and the question is refused; outside a count question, a street left over gets an address lookup of its own, or refuses the question while address lookups are off.
- When the SDK repairs a `GenerativeStrategy` or `AgentStrategy` query that repeats a root field with different arguments, the second one is keyed `search2` (or `aggregate2`), where it was `search_2`.

### Upgrading from 0.10.3

- Address lookups no longer select the five `Address` count fields (`associatedBrandCount`, `operatingLocationOperatesAtCount`, `legalEntityReceivesMailAtCount`, `registrationRecordedCount` and `associatedPersonCount`). An address result no longer carries those keys, so code that reads `address["associatedBrandCount"]` raises `KeyError`; read the operating locations, legal entities and registrations each address lists instead. With `Client`'s default strategy the hosted service builds the query, so this happens when the service adopts the release, whatever SDK version you run.
- `address_search="auto"`, and the check that refuses an address search before it is served, also take a schema that defines an `Address` type and the `Node` interface as one that supports address lookups, besides one whose `Address` type carries `associatedBrandCount`.
- When a question asks how many, other than a headcount, and the query is an address lookup that selects no count, `unavailable_data` has an `address counts` entry (`out_of_scope`).

### Upgrading from 0.10.2

- `SearchResponse.headers` holds the HTTP response headers, so you can read `Retry-After` on a 429. It takes no part in `==` or `repr`; `dataclasses.asdict(response)` and `dataclasses.astuple(response)` now include it as an `httpx.Headers`, which `json.dumps` can't serialize.
- A response body that isn't a JSON object, such as a gateway's error page, no longer raises `ValueError` (or `AttributeError` for JSON that isn't an object). `client.search` returns it with `data` set to `None`, an error in `errors` and the body in `raw_body`, so `is_success()` is false. `ServiceStrategy` raises `EnigmaClientException` with `INVALID_QUERY` for one, and `introspect_schema` raises its "no schema data" `RuntimeError`.
- A query written with no space after an opening brace, such as `node{name}`, is sent correctly. The client used to join the `id` and `__typename` it adds to the first field, and the API rejected the query.
- `EnigmaClientException.status_code` holds the HTTP status of the failed LLM provider or hosted-service call behind an error, when there was one, so retry logic needs no message parsing. For the hosted service, `EnigmaClientException.headers` holds its response headers. A mapped provider failure keeps the provider's error as `__cause__`. No error code changes for an HTTP status.
- An API key or header value an HTTP header can't carry as written, such as one with a newline or a trailing space, raises `ValueError` naming the header, before the API request is sent. It replaces the httpx error that could quote the value, which an `except httpx.HTTPError` handler no longer catches, and for a character outside ASCII the `UnicodeEncodeError`, which an `except UnicodeEncodeError` handler no longer catches. `introspect_schema`, which sent an API key outside ASCII encoded as UTF-8, now refuses it the same way.
- A custom `LlmProvider` can raise `ProviderHTTPError`, now exported from `enigma_api_client.strategies`, with the HTTP status of a failure. 408, 429 and 5xx are reported as `TRANSIENT_PROVIDER_ERROR`, any other HTTP status as `PROVIDER_ERROR`, and a value that is not an HTTP status as `TRANSIENT_PROVIDER_ERROR`.

### Upgrading from 0.10.1

- `Client.search` asks the API to wait up to 25 seconds for values it computes in the background, such as some counts, before it answers. The API answers a request with no background work at once. A `ClientConfig.url` that sets `max_background_task_wait` or `max_call_duration` keeps its own wait.
- `SearchResponse.is_success()` is false for an HTTP 206 answer, whose background values are still null. It is also false for an answer that reports a background task that failed, was cancelled or ran out of credits.
- `TemplateStrategy`'s brand watchlist template selects each watchlist entry's `id` in place of `internalWatchlistEntryId`, which the API is retiring. The two identifiers differ, so ids you stored from `internalWatchlistEntryId` won't match.

### Upgrading from 0.10.0

- An `unavailable_data` entry for something the selected government records answer only in part, such as "tax liens", gets the message "The returned records cover this only in part." when the query selects those records.
- The phrases "work permit", "export permit", "licensing agreement" and "drivers' license" no longer ask for government records.

### Upgrading from 0.9.14

- With `TemplateStrategy`, `GenerativeStrategy` or `AgentStrategy`, a question that asks for an entity's government or public records returns a query that selects them. Each returned entity carries the fields listed under Records on Each Entity. Beside a search by a name, person or street address, the response gains `archiveRecords`, plus `archiveBrandRecords` beside a brand or location search. A question that asks for no records returns the same query as in 0.9.14.
- `unavailable_data` leaves out an entry the selected records answer in full, such as "liquor licenses" or "violations". When the SDK adds record counts but none of the records the question asks for, such an entry carries the message "This query does not return these records." When the SDK adds no record fields, a `violations` entry says that violation records are served as the Gov Archive records of a business, location, person or address.

### Upgrading from 0.9.13

- The query reference `AgentStrategy` reads no longer lists `internalId`, which the API is retiring on every type.

### Upgrading from 0.9.12

- Generated queries select `pageInfo { hasNextPage endCursor }` on `Brand.affiliatedBrands` and `LegalEntity.legalEntities`, so a corporate family longer than one page can be paged with `after:`.

### Upgrading from 0.9.11

- `TemplateStrategy` lookups return a core set of fields plus only the data the question asks for (see TemplateStrategy). A plain "verify" question no longer returns TINs, watchlist entries, bankruptcies or officers; ask for them, or ask a compliance question.
- `TemplateStrategy`'s legal entity lookup searches by name only, and reads a state in the question as the registrations to list in that state.
- `TemplateStrategy` can refuse a question about several businesses or people at once with `UNSUPPORTED_DATA_REQUESTED`. From 0.12.0 it answers up to three at once (see Upgrading from 0.11.0).
- With `TemplateStrategy`, a question with no usable name, website or person name raises `UNSUPPORTED_DATA_REQUESTED` instead of being sent as an empty search.
- `TemplateStrategy`'s brand profiles select `operatingLocations` in place of the `hqAddress` alias, so the response key changes, and no longer select `isMarketables`.
- `TemplateStrategy` answers an officers question about a name of more than one word that ends in Inc, Incorporated, Co, Company, LLC, LP, LLP, LLLP or PLLC, with no website, by searching legal entities. `search` then returns a `LegalEntity` whose officers are under `registeredEntities`, `registrations` and `roles`. Other names, including names ending in Corp, Corporation or Ltd, still search brands.

### Upgrading from 0.9.10

- `restricted_fields_for` and `gate_blocked_fields` also list `placeClasses`, `siteTypes` and `tiers` for the `EXTERNAL` and `REDISTRIBUTION` tiers, and `legalEntitiesAppearsOn` and `legalEntitiesIsFlaggedBy` for `REDISTRIBUTION`; the API does not serve them to those tiers.
- `TemplateStrategy` answers a question about how many businesses of a category are in a place with a count instead of refusing it.

### Upgrading from 0.9.9

- `restricted_fields_for` and `gate_blocked_fields` no longer list `headcounts` for the `EXTERNAL` and `REDISTRIBUTION` tiers; the API serves it to every tier. A headcount question on a brand or legal entity now selects each legal entity's headcount band instead of declaring headcount in `unavailable_data`. For a query that is not a company or legal entity lookup, `TemplateStrategy` and `GenerativeStrategy` still declare `employees` (`out_of_scope`); `TemplateStrategy`'s entry has a new message.

### Upgrading from 0.9.8

- The SDK also calls a schema introspector registered with `enigma_api_client.validation.set_schema_introspector`, including the one `Client` registers, from a background thread in an empty context. It does so when a strategy needs a tier's schema and none is cached yet, and on the first use of a cached schema that is more than five minutes old. The introspector must be thread-safe, return in bounded time and not rely on context variables. `set_schema_introspector(introspector, refresh_seconds=None)` turns off the refresh of cached schemas; a missing schema is still fetched in the background. The introspector `Client` registers uses the five-minute interval.
- Whatever `address_search` says, a strategy refuses an address search, with the reason `bare address lookup`, while the cached schema for the access tier shows no support for address lookups (in 0.9.8, one that lacks `Address.associatedBrandCount`; see Upgrading from 0.10.3). With `address_search=True` it still serves one while no schema is cached.
- `TemplateStrategy` output and answers from the query shape cache drop any field your tier's schema doesn't define, besides the `restricted_fields_for` fields, and declare each in `unavailable_data` as `contractual`.

### Upgrading from 0.9.7

- `restricted_fields_for` and `gate_blocked_fields` list `tins`, `watchlistEntries`, `isFlaggedByWatchlistEntries` and `appearsOnWatchlistEntries` for the `REDISTRIBUTION` tier; the API no longer serves them to it.
- `GenerativeStrategy`, `TemplateStrategy` and `AgentStrategy` drop a field the tier is not served from the query they return and declare it in `unavailable_data` (`contractual`, keyed by the field's schema name), with `restricted-field` in `deterministic_repair`. A query left with nothing else, or whose filter, `orderBy` or `field` argument path still names such a field, raises `UNSUPPORTED_DATA_REQUESTED`.
- `ClientConfig.tier` (default `AccessTier.REDISTRIBUTION`) is the tier a local strategy generates for and validates against. Set it to your key's tier to keep TIN and watchlist selections with an external key; `ServiceStrategy` ignores it. Schema validation is process wide and follows the first `Client`'s tier, so a later `Client` at another tier validates without the schema and its `AgentStrategy` raises `PROVIDER_ERROR`. `Client.search` passes `tier=` to `generate_query` when `ClientConfig.tier` is not the default, so a custom `QueryStrategy` must accept a `tier` keyword argument.

### Upgrading from 0.9.1

- `__url` links point at the Enigma Console's profile pages: `https://console.enigma.com/profile/brand/<id>`, `/profile/legal-entity/<id>` and `/profile/operating-location/<id>`. Old links still redirect. A person's `__url` links to the brand it appears under, or to Enigma's website when it appears under none, instead of a person profile.

### Upgrading from 0.9.0

- `TemplateStrategy` counts a brand's locations ("How many Starbucks are in Texas?") with a brand search, so the count moves from `data.aggregate.locationsCount` to `data.search[0].locationsCount`, `brandsCount` is no longer returned, and `search` is empty when no brand matches. The count covers only that brand's open locations in the state and city the question names; before, it counted every open location, whatever the brand.

### Upgrading from 0.8.x

- `AgentStrategy` runs on Amazon Bedrock only and takes the keyword arguments `model`, `region`, `max_turns` and `max_budget_usd`; `anthropic_api_key` is removed. The calling identity needs `bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream` on the model.
- `AgentStrategy` raises instead of returning an empty query:
  - `UnrepairedQueryError` when its answer fails schema validation or does not parse.
  - `UNSUPPORTED_DATA_REQUESTED` from the refusal checks `GenerativeStrategy` applies.
  - `INVALID_QUERY` for an empty prompt or answer (the "not applicable" refusal), a run that ends without a query, an answer that doesn't match the agent's answer format, a mutation or subscription, an answer whose variables do not fit their declared types, one nested too deeply, one a check could not read, or one over 10,000 characters.
  - `TRANSIENT_PROVIDER_ERROR` or `PROVIDER_ERROR` when the model call or the CLI fails.
- `AgentStrategy` records its token usage in `GENERATION_USAGE`.
- The `anthropic` and `all` extras pin an exact `claude_agent_sdk` version (currently 0.2.121).
- `AgentStrategy` serves its answer inline with `{}` variables, refuses the call before the agent runs when the tier has no schema (`PROVIDER_ERROR`), and takes `address_search=` like `GenerativeStrategy`.

### Upgrading from 0.7.x or Earlier

- Custom `LlmProvider`: `complete()` now returns `enigma_api_client.strategies.Completion(text, truncated, input_tokens=..., cache_read_tokens=..., cache_write_tokens=..., output_tokens=...)` instead of `str`.
- Custom `QueryStrategy`: `generate_query(prompt, *, tier=DEFAULT_TIER, prior_attempt=None)` gained `prior_attempt`. Implementations must set `GENERATION_USAGE` to a fresh `LlmUsage()` on entry.
- `GenerativeStrategy` raises `TruncatedOutputError` and `UnrepairedQueryError`. Both subclass `EnigmaClientException` with code `INVALID_QUERY`, so existing handlers still catch them.
- LLM provider transport failures now raise `EnigmaClientException`: `TRANSIENT_PROVIDER_ERROR` for timeouts and connection errors, `GENERATE_QUERY_CALLBACK_NOT_CONFIGURED` for configuration faults. They used to surface as raw `httpx` exceptions.
- `restricted_fields_for` and `gate_blocked_fields` no longer list `isMentionedInMediaContents`, and now list `headcounts`, `corporateFamilies` and `customs` for the `EXTERNAL` and `REDISTRIBUTION` tiers.
- Address lookups are new and default to `auto` (see Look Up an Address). With `ENIGMA_ADDRESS_SEARCH=off`, a question that gives only an address raises `UNSUPPORTED_DATA_REQUESTED`; 0.7.11's `TemplateStrategy` sent it as a nameless operating location search that the API rejected with HTTP 400.
- `GenerativeStrategy` now refuses some drafts with `UNSUPPORTED_DATA_REQUESTED` instead of serving them: a person search by surname alone, an EIN or filing number placed in `name`, a wildcard name pattern, a street address placed in `name`, and a category count offered for a listing request.
- `AgentStrategy` logs its transcript at DEBUG on `enigma_api_client.strategies.agent`, not at INFO on the root logger.
- `TemplateStrategy` refuses a blank prompt with `INVALID_QUERY` before any LLM call.
