Metadata-Version: 2.4
Name: enigma-api-client
Version: 0.10.2
Summary: Enigma's Public API SDK
Author-email: Enigma Technologies <info@enigma.com>
License-Expression: Apache-2.0
Project-URL: Homepage, https://enigma.com
Project-URL: Documentation, https://documentation.enigma.com/
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Operating System :: OS Independent
Classifier: Intended Audience :: Developers
Classifier: Framework :: AsyncIO
Classifier: Typing :: Typed
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

Python SDK for the Enigma GraphQL business intelligence API. Query Enigma's database of 250M+ businesses, and the government records linked to them, using raw GraphQL or natural language prompts.

## Installation

Requires Python 3.11 or later.

```bash
# Core client: raw GraphQL plus every prompt strategy except AgentStrategy
pip install enigma-api-client

# AgentStrategy (Claude Agent SDK on Amazon Bedrock)
pip install "enigma-api-client[anthropic]"

# BedrockProvider (Claude on Amazon Bedrock)
pip install "enigma-api-client[bedrock]"

# Both of the above
pip install "enigma-api-client[all]"
```

## Quick Start
The client supports both raw GraphQL queries and natural language queries.
* The example below sends a raw GraphQL query.
* Later examples show the natural language strategies.

### Using GraphQL Queries

```python
import asyncio
from enigma_api_client import Client, ClientConfig

async def main():
    config = ClientConfig(enigma_api_key="your-api-key")
    async with Client(config) as client:
        # Send a handwritten GraphQL query
        response = await client.search(
            query="""query {
              search(searchInput: { name: "Starbucks", entityType: BRAND }) {
                ... on Brand {
                  id
                  names(first: 1) { edges { node { name } } }
                }
              }
            }"""
        )
        print(response.data)

asyncio.run(main())
```

> **Connection lifecycle.** A `Client` holds reusable keep-alive connections for the GraphQL endpoint and the query strategy. Reuse one client across requests and release it when done: use it as an async context manager (`async with Client(config) as client:`, shown above) or call `await client.aclose()`.

### Natural Language Query strategies

The client supports four strategies for turning natural language prompts into GraphQL queries. By default it uses `ServiceStrategy`, which sends the prompt to Enigma's hosted query generation service and authenticates with your Enigma API key, so `client.search(prompt="...")` works with no extra setup. To generate queries locally instead, pass a `TemplateStrategy`, `GenerativeStrategy`, or `AgentStrategy` via `ClientConfig.query_strategy`, and set `ClientConfig.tier` to your key's access tier (default `AccessTier.REDISTRIBUTION`, the most restrictive surface).

`TemplateStrategy` and `GenerativeStrategy` call Anthropic by default (the `ANTHROPIC_API_KEY` environment variable, or `anthropic_api_key=`); pass `llm_provider=` to use OpenRouter or Amazon Bedrock instead (see LLM providers below). `AgentStrategy` runs on Amazon Bedrock with AWS credentials and requires the `claude_agent_sdk` package (`pip install "enigma-api-client[anthropic]"`). `ServiceStrategy` runs generation server side and needs no LLM key.

With `TemplateStrategy`, `GenerativeStrategy` or `AgentStrategy`, a prompt that asks for an entity's government records, such as its UCC filings or permits, gets them with the entity (see Gov-archive records below).

#### Service Strategy (default)
Sends your prompt to Enigma's hosted query generation service over HTTPS, and query generation runs server side. It uses the template strategy by default. To choose another, construct one with your key, because a `ServiceStrategy` you create does not inherit the `Client`'s key (without `enigma_api_key=` it reads `ENIGMA_API_KEY`): `query_strategy=ServiceStrategy(query_strategy="generative", enigma_api_key="your-api-key")`, imported from `enigma_api_client.strategies`.

```python
import asyncio
from enigma_api_client import Client, ClientConfig

async def main():
    # ServiceStrategy is used when no query_strategy is configured
    async with Client(ClientConfig(enigma_api_key="your-api-key")) as client:
        # Brand lookup
        response = await client.search(prompt="Tell me about Chipotle")
        print(response.data)

        # Location search
        response = await client.search(prompt="Find Starbucks locations in California")
        print(response.data)

        # Person search
        response = await client.search(prompt="Find companies owned by Elon Musk")
        print(response.data)

        # KYB verification
        response = await client.search(prompt="Verify legal entity for Enigma Technologies in NY")
        print(response.data)

asyncio.run(main())
```

#### TemplateStrategy: LLM classified, template built

Uses an LLM to classify the prompt into one of 11 query templates (or into one of two refusal categories: category discovery, and prompts that name no business, person, or address) and to extract parameters, then assembles the GraphQL query deterministically from the matching template. Fast and reliable, with no hallucinated field names, because the GraphQL comes from tested templates. Two more templates join the menu when they apply: address lookups when address search is on (see Address lookups below), and card transactions for an entitled tier (`ClientConfig.tier=AccessTier.EXTERNAL`, or `await strategy.generate_query(prompt, tier=AccessTier.EXTERNAL)`).

```python
import asyncio
from enigma_api_client import Client, ClientConfig
from enigma_api_client.strategies import TemplateStrategy

async def main():
    config = ClientConfig(enigma_api_key="your-api-key", query_strategy=TemplateStrategy())
    async with Client(config) as client:
        # Brand lookup
        response = await client.search(prompt="Tell me about Chipotle")
        print(response.data)

        # Location search
        response = await client.search(prompt="Find Starbucks locations in California")
        print(response.data)

        # Person search
        response = await client.search(prompt="Find companies owned by Elon Musk")
        print(response.data)

        # KYB verification
        response = await client.search(prompt="Verify legal entity for Enigma Technologies in NY")
        print(response.data)

asyncio.run(main())
```

The template strategy covers query patterns such as: brand profiles, brand lookups by website, locations, person search, corporate officers, legal entity verification, contacts, watchlist screening (for tiers served watchlist entries), operating location profiles, address lookups (what is at a street address), aggregate counts, and risk assessment. Prompts that don't map to a supported template (for example, open ended category discovery like "list coffee shops in Denver") raise an `EnigmaClientException`.

#### GenerativeStrategy: end to end LLM query generation

Uses a single LLM call to generate the full GraphQL query and variables directly. The LLM receives an excerpt of the bundled Enigma GraphQL skill reference plus fixed query patterns and guardrails as context. More flexible than TemplateStrategy for complex or unusual queries that don't fit the templates, but slower, and it carries some risk of hallucinated field names. Generated queries are validated against the API schema and repaired (deterministically where possible, otherwise with one more LLM call) before they are returned.

```python
import asyncio
from enigma_api_client import Client, ClientConfig
from enigma_api_client.strategies import GenerativeStrategy

async def main():
    config = ClientConfig(enigma_api_key="your-api-key", query_strategy=GenerativeStrategy())
    async with Client(config) as client:
        response = await client.search(
            prompt="Find all open Dunkin' Donuts locations in Massachusetts with phone numbers"
        )
        print(response.data)

asyncio.run(main())
```

#### AgentStrategy: Claude Agent SDK with skill

Uses the Claude Agent SDK (`claude_agent_sdk`) to generate GraphQL queries through an agent that can read the full `enigma-graphql` skill, including the field whitelist, examples, field corrections, and pagination templates. The agent reasons about the query and refines its output, and the strategy validates the answer against the schema before returning it. It produces the highest quality queries for complex scenarios but is the slowest option.

The agent runs on Amazon Bedrock only. Credentials come from the standard AWS credential chain, and the identity needs `bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream` on the inference profile and on the foundation model in every region the profile routes to (default model `us.anthropic.claude-haiku-4-5-20251001-v1:0`; pass `model=` and `region=` to change it, and keep the region inside the profile's geography). Its one tool reads files inside the bundled plugin: it has no shell, file writes, web access, or file attachments, it runs with every inherited environment variable blanked except process basics, TLS and proxy settings and the AWS credential chain, and it keeps no session transcript on disk. Its answer is written inline (variables become literals typed against the tier's schema) and passes the same post-draft gates and checks as `GenerativeStrategy`, with the deterministic repair but no model repair pass. `max_turns` (default 8) and `max_budget_usd` (default 0.25) bound each call.

Requires: `pip install "enigma-api-client[anthropic]"`

```python
import asyncio
from enigma_api_client import Client, ClientConfig
from enigma_api_client.strategies import AgentStrategy

async def main():
    config = ClientConfig(enigma_api_key="your-api-key", query_strategy=AgentStrategy())
    async with Client(config) as client:
        response = await client.search(
            prompt="Find all open Dunkin' Donuts locations in Massachusetts with phone numbers"
        )
        print(response.data)

asyncio.run(main())
```

## Address lookups

`TemplateStrategy` and `GenerativeStrategy` answer "what is at this address" prompts: a street address with a house number and no business name, such as "What is at 100 Main St, Wichita, KS?" or "Who is registered at 200 Oak Ave, Floor 8, Springfield, IL?". The query is an `entityType: ADDRESS` search limited to 10 results. It returns the matched building and its units (up to 10 rows); when the prompt names a unit, that unit usually comes first. Each result carries the address fields, five counts (`associatedBrandCount`, `operatingLocationOperatesAtCount`, `legalEntityReceivesMailAtCount`, `registrationRecordedCount`, `associatedPersonCount`), and the operating locations (with brand names), legal entities, and registrations at the address.

- A business name plus an address ("the Starbucks at 123 Main St") stays an operating location lookup.
- A ZIP code alone, a street with no house number, or a business type on a street ("the barbershop on Elm Street") raises `EnigmaClientException` with `UNSUPPORTED_DATA_REQUESTED`.
- Before the query is sent, the SDK cleans the address the model extracted. It requires a house number, moves a suite, floor, or apartment into `street2` in USPS short form (`STE 1A`, `FL 8`), drops a unit designator with no number, removes surrounding prose and a trailing country, and converts state names to their postal codes (such as `NY`). `GenerativeStrategy.generate_query()` and `AgentStrategy.generate_query()` include `address-input` in `deterministic_repair` for a cleaned query.
- `TemplateStrategy`, `GenerativeStrategy`, and `AgentStrategy` never return a query that selects `brandViaLegalEntityCount`, `brandViaPersonLegalEntityCount`, or `legalEntityViaRegistrationCount`. `GenerativeStrategy` and `AgentStrategy` remove them when they can and include `deferred-field` in `deterministic_repair`; otherwise `GenerativeStrategy` repairs the query with the model, and `AgentStrategy` raises `EnigmaClientException` with `UNSUPPORTED_DATA_REQUESTED` (or `UnrepairedQueryError` when a repair would land on one or the answer does not parse).
- Turn it on or off with `address_search=` on `TemplateStrategy`, `GenerativeStrategy`, or `AgentStrategy` (`True`, `False`, or `"auto"`), or with the `ENIGMA_ADDRESS_SEARCH` environment variable (`on`, `off`, or `auto`; unset or unrecognized means `auto`). The setting is read when the strategy is constructed.
- In `auto` mode, each strategy instance decides at its first prompt for an access tier whether the API schema supports address search, and keeps that answer, even one from a failed schema request, for the life of the instance. `Client` supplies the schema for its `ClientConfig.tier`. With no schema (no `Client` created in the process and no introspector registered through `enigma_api_client.validation.set_schema_introspector`, or a schema request that failed at that first prompt), `auto` stays off.
- With address search off, a prompt that gives only an address is refused; on `TemplateStrategy` the reason is `bare address lookup`.
- Whatever the setting, `TemplateStrategy`, `GenerativeStrategy` and `AgentStrategy` refuse to return an address search, with the reason `bare address lookup`, while the cached API schema for the access tier lacks `Address.associatedBrandCount`, the field that marks address search support. With `on` and no schema cached, they return the address search.
- `ServiceStrategy` doesn't take this setting. `AgentStrategy` takes it like `GenerativeStrategy`: its answer's ADDRESS searches pass the same address gate.

```python
import asyncio
from enigma_api_client import Client, ClientConfig
from enigma_api_client.strategies import TemplateStrategy

async def main():
    config = ClientConfig(enigma_api_key="your-api-key", query_strategy=TemplateStrategy(address_search=True))
    async with Client(config) as client:
        response = await client.search(prompt="What is at 1600 Pennsylvania Ave NW, Washington DC?")
        for address in response.data["search"]:
            print(address["fullAddress"], address["associatedBrandCount"])

asyncio.run(main())
```

## Template blocks

`TemplateStrategy` answers company, brand, location, officer and person lookups with a core selection plus only the data the prompt asks for (industry, website, phone, officers, bankruptcies, watchlists, TINs, registration details, locations, affiliates, alternate names, address, reviews). A compliance ask (KYB, due diligence, background check) adds officers, TINs, watchlists, bankruptcies and registration details; an ask for everything adds every block. Each lookup adds only the blocks its entity carries (a location lookup has no officers block). TINs and watchlist entries reach only keys entitled to them; a redistribution key gets them listed in `unavailable_data`. It searches a legal entity by name only and refuses (`UNSUPPORTED_DATA_REQUESTED`) a lookup with no name, website or usable person name.

## Gov-archive records

Enigma's 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. Enigma links most records to the businesses, locations, people and addresses they name. With `TemplateStrategy`, `GenerativeStrategy` or `AgentStrategy`, when a prompt names an entity and asks for its records, the generated query selects:

- on each entity the search returns, the number of records linked to it, and, when the prompt asks for records in general, a page of them;
- `archiveRecords` (and `archiveBrandRecords` beside a brand or location search): searches of the archive by the same name, person or street address, which return the records naming it, linked to an entity or not.

A prompt asks for records when it names one of these use cases:

- 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

The archive searches keep the records carrying a tag of the use cases the prompt names. A dataset can carry a use case's tag without belonging to it, so check `sourceTitle`. A prompt that asks for "public records", "government records" or the "gov archive" in general gets records of every kind, unless it also names a use case. The names the query searches by are skipped, so a prompt about a business named "Permit Pros" asks for no permits. Records come unsorted, not by date.

### Records on each entity

Each entity the search returns gains these fields after its own:

| Field | Holds |
|---|---|
| `recordCount` | The number of records linked to the entity |
| `recordSourceCounts`, `recordTagCounts` | On an address only: the record count per dataset title and per tag |
| `recordSources` | On a legal entity the search returns (not one under a brand or location): its first three registered entities, each with `recordSourceCounts` and `recordTagCounts`, with page info |
| `records` | For a prompt asking for records in general: the first ten linked records when the search returns one entity (`limit: 1`), otherwise the first three, with `pageInfo { hasNextPage endCursor }` |

Legal entities the query selects directly under a returned brand or operating location get `recordCount` too, and, for a general ask, three `records`, with no counts by dataset or tag.

For a prompt naming use cases, the entities get no `records` page, because filtering a large entity's linked records takes longer than the API allows. The use cases' records come from the archive searches. A brand, location or person shows only its total `recordCount`, over every dataset; an address, or a legal entity the search returns (through `recordSources`), also counts its linked records per tag in `recordTagCounts`.

### Records from the archive

`archiveRecords` repeats the search beside it with `dataLayer: UNGROUNDED`: its name, or a person's first and last names (both are needed), its street address when it has a `street1` (alone only on an address search), and its entity type. Beside a brand or location search by name it searches legal entities, the names most records state, and `archiveBrandRecords` searches brands, which finds records that state a brand or trade name; the two can return the same record. Each returns the entities its search matches, with only `__typename`, followed by up to 50 records whose own data names them, linked to an entity or not and carrying a tag of the named use cases (any tag, for a general ask). Read the items whose `__typename` is `Record`. Beside a second or later search the keys carry its number (`archiveRecords2`, `archiveBrandRecords2`). The archive matches names loosely, and the archive search leaves out a city or state given without a street, so a record can belong to another business or person with a similar name, anywhere: check the name in its `data`. A search with no name, person or street address, such as one by website alone, gets no archive search. Set `ENIGMA_ARCHIVE_SEARCH=off` (or `0`, `false`, `no`) to leave them out.

The archive searches miss many records of these use cases: UCC and lien filings, contractor licenses, PPP loans, government contracts, SAM.gov registrations, workers' compensation, lobbying and Form 990 filings. For these, a prompt often returns few or no records.

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 `ct`.
- `firstObservedDate`, `lastObservedDate`: when Enigma first and last saw the row. These are usually not the filing dates.
- `data`: the row itself, as column names and values. The filing dates are here.

```python
import asyncio
from enigma_api_client import Client, ClientConfig
from enigma_api_client.strategies import TemplateStrategy

async def main():
    strategy = TemplateStrategy()
    config = ClientConfig(enigma_api_key="your-api-key", query_strategy=strategy)
    async with Client(config) as client:
        response = await client.search(prompt="Show business licenses held by Walgreens")
        for entity in response.data["search"]:
            print(entity["__typename"], "linked records of every kind:", 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())
```

For this prompt the query gains a record count on the brand and on each of its legal entities, and two archive searches beside the brand search (tag lists shortened):

```graphql
... on Brand {
  id
  # the rest of the brand's own fields
  legalEntities(first: 3) {
    edges { node {
      id
      # names and types
      recordCount: count(field: "records")
    } }
  }
  recordCount: count(field: "records")
}
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 }
}
archiveBrandRecords: search(searchInput: { name: "Walgreens", entityType: BRAND, dataLayer: UNGROUNDED,
                                           conditions: { limit: 50, filter: { IN: ["tags", ["abc_license", "active_occupational_licenses", ...]] } } }) {
  __typename
  ... on Record { sourceTitle sourceUrl tags firstObservedDate lastObservedDate data }
}
```

The response, abbreviated:

```json
{
  "search": [{ "__typename": "Brand", "id": "...", "recordCount": 5711 }],
  "archiveRecords": [
    { "__typename": "LegalEntity" },
    {
      "__typename": "Record",
      "sourceTitle": "Business Licenses",
      "sourceUrl": "https://data.littlerock.gov/Economic-Development/Business-Licenses/vthq-dt7e",
      "tags": ["ar", "business_license"],
      "firstObservedDate": "2026-09-23 19:12:42+00",
      "lastObservedDate": "2026-09-23 19:12:42+00",
      "data": { "license_type": "BEER SALES", "license_number": "AL130018", "business_name": "WALGREENS #05730", "city": "LITTLE ROCK", "state": "AR" }
    }
  ],
  "archiveBrandRecords": [{ "__typename": "Brand" }, { "__typename": "Record", "sourceTitle": "Florida DBPR Retail Alcoholic Beverage Licenses" }]
}
```

Each `records` connection returns one page per entity. To read more of one entity's records when its `pageInfo.hasNextPage` is true, search that entity alone (`searchInput: { id: "<its id>", entityType: <its type>, conditions: { limit: 1 } }`) with the same `records` arguments plus `after: "<endCursor>"`. Keep `first` and `conditions` as they are, because the cursor is bound to them. To get a query you can adapt, call `await strategy.generate_query(prompt)` inside the `async with` block (the `Client` supplies the schema); Fetching the next page under Pagination below shows the loop. `archiveRecords` returns one page, with no cursor.

The installed package lists the use cases in `enigma_api_client/plugins/enigma-api/skills/enigma-graphql/references/record_topics.json`. Each entry holds the phrases that ask for it and the record tags that select it. To print them:

```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])
```

`TemplateStrategy`, `GenerativeStrategy` and `AgentStrategy` add the records to the query they return once the schema for your key's tier is cached (see Pagination below). `ServiceStrategy` returns what the hosted service generates. A query that already selects `records`, `archiveRecords` or `archiveBrandRecords`, and an `aggregate` count, stay as they are.

`unavailable_data` leaves out an ask the selected records answer in full, such as "liquor licenses". When the SDK adds record counts but none of the records the prompt asks for (a use-case ask on a search by website alone, or with `ENIGMA_ARCHIVE_SEARCH=off`), such an entry stays with the message "This query does not return these records." An entry for an ask the selected 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 none of those records, it stays as written.

## Strategy comparison

| Strategy | Approach | Speed | Quality | Extra deps |
|---|---|---|---|---|
| `ServiceStrategy` (default) | Delegates generation to Enigma's hosted service | network bound | Server managed, always current | None |
| `TemplateStrategy` | LLM classifies, then a deterministic template build | about 1 to 3 s | Reliable, no hallucination | None |
| `GenerativeStrategy` | LLM generates the full GraphQL end to end | about 3 to 10 s | Flexible, some hallucination risk | None |
| `AgentStrategy` | Claude agent on Amazon Bedrock that reads the skill | about 10 to 60 s | Highest quality, iterative refinement | `claude_agent_sdk` |

**When to use which:**

- **ServiceStrategy** (default): Generation runs on Enigma's servers, so you get the latest query logic with no local model calls and no LLM key. Best default for most callers.
- **TemplateStrategy**: Best when you want deterministic local generation. Covers the common query patterns with 12 templates and no hallucination risk. The LLM only classifies and extracts parameters, and the GraphQL is deterministic.
- **GenerativeStrategy**: Best for complex or unusual queries that don't fit the templates. The LLM generates custom GraphQL from the API reference and fixed patterns. Good for prototyping and exploratory queries.
- **AgentStrategy**: Best for maximum quality when latency is not a concern. The agent can read examples, check field corrections, and reason about the query. Good for batch processing or one off complex queries.

## LLM providers

`TemplateStrategy` and `GenerativeStrategy` take any LLM provider through `llm_provider=`. Without one they use `AnthropicProvider` with `anthropic_api_key=` or `ANTHROPIC_API_KEY`; the strategy's `model=` argument applies only to that default provider.

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

# Anthropic Messages 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 the 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 ambient AWS credentials.
# The default model is a US inference profile; pass model= outside US regions.
TemplateStrategy(llm_provider=BedrockProvider(region="us-east-1"))
```

A custom provider implements the `LlmProvider` protocol: `async def complete(system, user, max_tokens) -> Completion`. Raise `ValueError` for a missing key or bad configuration (reported as `GENERATE_QUERY_CALLBACK_NOT_CONFIGURED`) and `RuntimeError` for a provider failure (reported as `TRANSIENT_PROVIDER_ERROR`); any other exception propagates unwrapped.

## Environment variables

| Variable | Used by | Meaning |
|---|---|---|
| `ENIGMA_API_KEY` | `ServiceStrategy` | `ServiceStrategy`'s key when `enigma_api_key=` is not passed (`Client` passes its own key only to the default `ServiceStrategy` it creates) |
| `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` disables the query shape cache |
| `ENIGMA_ADDRESS_SEARCH` | `TemplateStrategy`, `GenerativeStrategy`, `AgentStrategy` | `on`, `off`, or `auto` (see Address lookups above) |
| `ENIGMA_ARCHIVE_SEARCH` | `TemplateStrategy`, `GenerativeStrategy`, `AgentStrategy` | `off`, `0`, `false` or `no` leaves the archive searches out of a records query (see Gov-archive records above); read on each call |

## Query shape cache

`TemplateStrategy` and `GenerativeStrategy` keep an in-process cache of query shapes learned from successful generations. Once a shape has been confirmed across several different entities, a matching prompt is served 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. Disable it with `shape_cache=False` or `ENIGMA_SHAPE_CACHE_ENABLED=0`.

## Errors and response fields

Generation failures raise `EnigmaClientException`; its `error_code` is an `EnigmaClientErrorCode`:

| Code | Meaning |
|---|---|
| `INVALID_QUERY` | The prompt or the generated query is unusable (includes the two subclasses below) |
| `UNSUPPORTED_DATA_REQUESTED` | Enigma does not serve what the prompt asks for |
| `GENERATE_QUERY_CALLBACK_NOT_CONFIGURED` | A missing key or a provider configuration fault |
| `TRANSIENT_PROVIDER_ERROR` | The LLM provider timed out, rate limited, or failed with a 5xx; retry with backoff |
| `PROVIDER_ERROR` | Any other provider failure (for example 401 or 403); retrying will not help |

`RESTRICTED_DATA_REQUESTED` and `INVALID_VARIABLES` exist for callers that enforce their own policies; the SDK does not raise them.

The provider codes come from `TemplateStrategy` and `GenerativeStrategy`. `ServiceStrategy` reports any hosted service failure (any status other than 200, including 401, 429, and 5xx, or a GraphQL error) as `INVALID_QUERY`, and its network errors (timeouts, connection failures) propagate as `httpx` exceptions, as do network errors from `Client.search` itself.

Both exceptions below import from `enigma_api_client`.

- `TruncatedOutputError`: the model hit its output token cap. This repeats for the same model and prompt, so do not retry with the same model.
- `UnrepairedQueryError`: `GenerativeStrategy` produced a query that stayed invalid 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)`.

`SearchResponse.unavailable_data` lists the parts of a prompt Enigma cannot serve, and the fields your key's tier is not served that the query left out (keyed by schema name, `contractual`), as `{"field", "reason", "message"}` entries (`reason` is `contractual` or `out_of_scope`), while the rest of the prompt is still answered. With a local strategy, an ask the selected gov-archive records answer in full, such as "liquor licenses", is not listed (see Gov-archive records above).

Calling a strategy directly (`await strategy.generate_query(prompt)`) returns a `GenerateQueryResponse` with `query`, `variables`, `unavailable_data`, and diagnostics such as `cache_rule` (set when the shape cache took part) `deterministic_repair` (the SDK's rewrite kinds, when it repaired the query without a model call) and `records` (local strategies, when the prompt asks for gov-archive records: `selected` when the SDK added record fields, which can be counts alone, or why it added none: `no_schema`, `no_entity`, `already_selected`, `invalid`). After a call, `enigma_api_client.strategies.GENERATION_USAGE.get()` returns that call's token counts (`LlmUsage`) and `GENERATION_STAGE.get()` its last generation stage; read them in the same task that awaited the call. `ServiceStrategy` reports zero usage; `AgentStrategy` reports its run's totals.

## Logging

The SDK's logging surface is split by level according to content. Raw model text (the
agent transcript, the prose or non JSON draft echoes, the validator detail behind
a failed repair) logs only at DEBUG on the emitting module's logger
(`enigma_api_client.*`); enabling DEBUG on those loggers (or globally) writes
prompts and model output to your log sink, so do not enable it in environments
with data retention constraints. WARNING and INFO lines are fixed templates
carrying counts, statuses, rule and kind vocabularies, and file paths, with no
raw model or validator text.

## Pagination

Connections (any field returning `edges { node { ... } }`) return at most one page at a time. Getting a full page back (for example 100 rows) does not tell you whether more exist. You need `pageInfo` for that.

`TemplateStrategy`, `GenerativeStrategy` and `AgentStrategy` select `pageInfo { hasNextPage endCursor }` on two connections without any setting: a brand's affiliated brands (`Brand.affiliatedBrands`) and a legal entity's parent and subsidiary legal entities (`LegalEntity.legalEntities`). The last two add it once the schema for your key's tier is cached (the first `Client` in a process supplies the schema for its `ClientConfig.tier`). The gov-archive `records` connections they add carry it too (see Gov-archive records above). To read past the first page, add `after:` to that connection and page it as shown under Fetching the next page.

### Automatic page info injection

Set `auto_inject_page_info=True` on the client and it rewrites every outgoing query, adding `pageInfo { hasNextPage endCursor }` to every connection that does not already request it. This applies to both raw GraphQL and prompt generated queries:

```python
config = ClientConfig(
    enigma_api_key="your-api-key",
    auto_inject_page_info=True,
)
```

- `hasNextPage` is an authoritative "is there more?" flag from the backend. Use it instead of inferring truncation from a full page of results. It stays correct even if the page size changes, so there is nothing to keep in sync on your end.
- `endCursor` is the cursor for the next page.

The flag is off by default.

### Fetching the next page

Re-run the *same* query with `after: $cursor` on the connection, passing the previous page's `endCursor`. On the first page pass `cursor: null` (null starts from the beginning; *omitting* a declared `$cursor` variable is what triggers an HTTP 400 "Undefined variable"). A single parameterized query paginates every page, so there is no separate first page query.

```python
# With auto_inject_page_info=True you can omit the explicit `pageInfo` line below;
# the client adds it for you. It is shown here so the response shape is clear.
query = """query Locations($cursor: String) {
  search(searchInput: { name: "Starbucks", entityType: BRAND }) {
    ... on Brand {
      operatingLocations(first: 100, after: $cursor) {
        edges { node { id } }
        pageInfo { hasNextPage endCursor }
      }
    }
  }
}"""

async def all_location_ids(client):
    cursor, ids = None, []
    while True:
        response = await client.search(query=query, variables={"cursor": cursor})
        connection = response.data["search"][0]["operatingLocations"]
        ids += [edge["node"]["id"] for edge in connection["edges"]]
        page = connection["pageInfo"]
        if not page["hasNextPage"]:
            return ids
        cursor = page["endCursor"]
```

Keep the connection's `conditions` and `orderBy` fixed across a run, since cursors are bound to the arguments that minted them.

**`roles` does not paginate.** The schema advertises `last`, `after`, and `before` on `roles`, but the API rejects all three at execution (HTTP 400). Fetch roles as a single page with `first: N`, optionally narrowed with `conditions`.

## Caveats: LLM generated GraphQL

The Enigma GraphQL schema has several non obvious conventions that make LLM generated queries unreliable without guardrails. Understanding these is important regardless of which strategy you use.

### Why this is hard for LLMs

The Enigma schema has about 50 field names, 5 searchable entity types (`BRAND`, `OPERATING_LOCATION`, `LEGAL_ENTITY`, `PERSON`, `ADDRESS`), 10+ filter operators, and 6 connection patterns. Many field names are unintuitive, and LLMs consistently guess wrong:

| LLMs guess | Actual field name |
|---|---|
| `status` | `operatingStatus` |
| `url` | `website` |
| `description` | `industryDesc` |
| `title` | `jobTitle` |
| `averageRating` | `reviewScoreAvg` |

These cause HTTP 400 errors ("Cannot query field") that are silent failures if not handled.

### Structural pitfalls

Beyond field names, the graph structure has patterns that LLMs regularly get wrong:

- **`search` returns a flat list, not a connection.** LLMs write `search { edges { node { ... } } }`; the correct form is `search { ... on Brand { ... } }`.
- **Every `search` needs a retrieval handle.** A `searchInput` without `name`, `website`, `person`, or `phoneNumber` is schema valid but rejected at execution with HTTP 400 "Search must include name, website, person, or phone_number". Filters never retrieve. An address alone retrieves only as `entityType: ADDRESS` with `address.street1` (a street line with a house number) or `address.id` (see Address lookups above); `id` and `tin` lookups also resolve an entity directly.
- **`LegalEntity.persons` is almost always empty.** To find officers, traverse `registeredEntities`, then `registrations`, then `roles`, then `legalEntities`. LLMs take the direct (empty) path.
- **Searching `OPERATING_LOCATION` by brand name returns nothing.** Search `BRAND` and filter its `operatingLocations` connection. LLMs don't know this.
- **Do not use `prompt` on SearchInput.** The field passes schema validation, but the API rejects any query that combines `prompt` with a selection set (HTTP 400 at execution). Retrieve by `name`, `website`, or `person` instead. There is no semantic discovery mode: `name: "coffee shop"` returns nothing useful, and category counts go through `aggregate`.
- **`totalCount` does not exist on connections.** Requesting it causes HTTP 400. Use `aggregate` queries instead.
- **Cursor pagination uses one query, not two.** A single query with `$cursor: String` and `after: $cursor` paginates every page. Pass `cursor: null` on page 1 (null starts from the beginning; *omitting* the declared variable is what causes HTTP 400 "Undefined variable"). Keep the connection's `conditions` and `orderBy` fixed across a run, since cursors are bound to the arguments that minted them.

### Rules enforced at execution, not in the schema

Some constraints are not expressed in the GraphQL schema: a query can pass schema validation (or any introspection based tooling) and still be rejected when it executes. Known rules include:

| Rule | Rejection |
|---|---|
| `search` requires a retrieval handle: `name`, `website`, `person`, or `phoneNumber` (`id` and `tin` lookups also resolve directly). Filters never retrieve; an address alone retrieves only as `entityType: ADDRESS` with `address.street1` or `address.id`. | `Search must include name, website, person, or phone_number` |
| `prompt` cannot be combined with a selection set; avoid the field entirely. | `'prompt' cannot be combined with a selection set` |
| `roles` supports `first` and `conditions` only, even though the schema advertises cursor arguments. | `LegalEntity.roles does not currently support last, after or before` (the error names the parent type) |
| A PERSON search retrieves by `person: { firstName, lastName }` (or `id` / `tin`), not by `name`. | `Person search must include an identifier or first, middle or last name` |
| Filter `AND`, `OR`, and `NOT` take a list of clauses, and `NOT` takes exactly one. | Rejected at execution |
| A filter clause carries exactly one operator key; combine several under one `AND` list. | Rejected at execution |
| `IS_NULL` and `IS_NOT_NULL` test a column. On a connection path (such as `registrations`), test for related rows with `HAS` or `NOT: [{ HAS: [...] }]`. | Rejected at execution |
| 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` |

`GenerativeStrategy` checks these rules offline (`ess_runtime_rule_violations`) and repairs or refuses before returning a query; this also covers `ServiceStrategy` calls that run the generative strategy server side. The `prompt=` argument to `client.search()` is the natural language request, unrelated to `SearchInput.prompt`. Other strategies and raw GraphQL callers should conform to the rules directly.

### Data quality traps

Even syntactically valid queries can return silently wrong data:

- **Franchise resolution:** Searching `BRAND` for a local franchise (for example, a single Days Inn) returns the parent chain's data: 1,969 locations rolled up instead of the single franchisee.
- **`websiteContents` is a connection, not a scalar.** Use `websiteContents(first: 1) { edges { node { httpStatusCode } } }`.

### How the strategies handle this

**TemplateStrategy** avoids all of the above. The LLM only classifies the prompt into a template and extracts parameters (company name, state, city, and so on). The GraphQL is assembled from 12 hand written, tested templates (plus the card and address templates when they apply). No hallucination risk, but limited to the patterns those templates cover.

**GenerativeStrategy** sends an excerpt of the skill reference plus fixed query patterns and guardrails as system prompt context, validates the result against the schema and the rules above, and repairs it when needed. This covers most pitfalls, but the LLM can still pick the wrong fields or miss structural rules, especially for unusual queries. Review generated queries before using them in production.

**AgentStrategy** has full access to the skill files, examples, and field corrections reference, and can refine queries iteratively. Highest quality, but slowest and most expensive. The strategy validates the agent's answer against the schema and raises `UnrepairedQueryError` when it fails.

### Recommendations

- **For production automation**, use `TemplateStrategy` or raw GraphQL. The templates cover the most common query patterns and are fixed, tested queries.
- **For prototyping or one off complex queries**, use `GenerativeStrategy` or `AgentStrategy`, but always review the generated query before trusting the results.
- **Always check `response.is_success()`** and inspect `response.errors` before using `response.data`. A plausible looking query can still fail at the API level.

## Upgrading from 0.10.1

- `Client.search` sends `max_background_task_wait=25` and `max_call_duration=25`, so the API holds the request up to 25 seconds, its longest wait, for work it does in the background, such as a count or an attribute it fetches on demand. A request with no such work returns at once; an export is accepted after up to 25 seconds. A `ClientConfig.url` that sets either parameter keeps its own wait.
- `SearchResponse.is_success()` is false for an HTTP 206 answer, which leaves the values still computing in the background null, and for an answer listing a background task that failed, was cancelled or ran out of credits.

## Upgrading from 0.9.14

- With `TemplateStrategy`, `GenerativeStrategy` or `AgentStrategy`, a prompt that asks for an entity's government or public records returns a query that selects them, so each returned entity carries the fields listed under Gov-archive records above, and the response gains `archiveRecords` (and `archiveBrandRecords` beside a brand or location search) beside a search by a name, person or street address. A prompt that asks for none 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 prompt asks for, such an entry carries the message "This query does not return these records." A `violations` entry otherwise says that violation records are served as the gov-archive records of a business, location, person or address.

## 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.

## 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 ask on a brand or legal-entity query selects each legal entity's headcount band instead of declaring headcount in `unavailable_data`; an `employees` entry (`out_of_scope`), with a new message, remains on `TemplateStrategy` and `GenerativeStrategy` for a query that is not a company or legal-entity lookup.

## Upgrading from 0.9.8

- A schema introspector registered with `enigma_api_client.validation.set_schema_introspector`, the one `Client` registers included, is also called from a background thread in an empty context: to fetch a tier schema the strategies read (and the INTERNAL one, for another tier) when none is cached yet, and on the first use of a cached schema after it is five minutes old. It must be thread-safe, return in bounded time and not rely on context variables. `refresh_seconds=None` turns off the refresh of cached schemas; a missing schema is still fetched in the background.
- With `address_search=True` (or `"on"`), a strategy refuses an address search, with the reason `bare address lookup`, while the cached schema for the access tier lacks `Address.associatedBrandCount`.
- `TemplateStrategy` output and graduated shape-cache hits drop, and declare in `unavailable_data`, any field the cached schema for the access tier does not define, besides the `restricted_fields_for` fields. In a `GenerativeStrategy` draft or an `AgentStrategy` answer, a connection the API stops serving the tier is dropped and declared the same way once the INTERNAL schema is cached, which takes an introspector that serves the INTERNAL tier (the one `Client` registers serves only its own tier); any other field the tier's schema lacks takes the repair pass in a generative draft and raises `UnrepairedQueryError` from an agent answer. Each dropped field is declared `contractual`, a field the API stops serving on every tier included.

## 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 a non-default tier to `generate_query` as `tier=`, which a custom `QueryStrategy` accepts under the `QueryStrategy` contract.

## 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 gates `GenerativeStrategy` applies, `INVALID_QUERY` for an empty prompt or answer (the "not applicable" refusal), a run that ends without a query, an answer off its schema, a mutation or subscription, an answer whose variables do not fit their declared types, one nested too deeply, or one a check could not read, and `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 `claude_agent_sdk==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`), refuses a mutation or subscription (`INVALID_QUERY`), and takes `address_search=` like `GenerativeStrategy`.

## Upgrading from 0.7.11 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 Address lookups above). With `ENIGMA_ADDRESS_SEARCH=off`, a prompt 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.
