Metadata-Version: 2.5
Name: naget
Version: 0.3.2
Summary: Official Python client and command-line interface for Naget
Project-URL: Homepage, https://naget.com
Project-URL: Documentation, https://naget.com/developer/docs
Author: Naget
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pydantic<3,>=2.7
Requires-Dist: rich<15,>=13.7
Requires-Dist: typer<1,>=0.12
Provides-Extra: codegen
Requires-Dist: datamodel-code-generator==0.72.4; extra == 'codegen'
Provides-Extra: keyring
Requires-Dist: keyring<27,>=25; extra == 'keyring'
Provides-Extra: test
Requires-Dist: pytest-asyncio<2,>=0.23; extra == 'test'
Requires-Dist: pytest<10,>=8; extra == 'test'
Description-Content-Type: text/markdown

# Naget Python SDK

The `naget` package provides a synchronous client (`Naget`), an asynchronous
client (`AsyncNaget`), the generated wire models, and the `naget`
command-line interface for the Naget `/v1` API. It targets Python 3.11 or
newer.

Routes, search modes, and worked examples in every language are in the
[Naget API docs](https://naget.com/developer/docs). This page is the Python-specific reference; the
[TypeScript SDK](https://www.npmjs.com/package/@naget/sdk) behaves the same way.

## Contents

- [Install](#install)
- [Configuration](#configuration)
- [Quickstart](#quickstart)
- [Resource reference](#resource-reference)
- [Pagination](#pagination)
- [Waiting on asynchronous work](#waiting-on-asynchronous-work)
- [Streaming search events](#streaming-search-events)
- [Idempotency and retries](#idempotency-and-retries)
- [Errors](#errors)
- [Models](#models)
- [Escape hatch](#escape-hatch)
- [CLI](#cli)
- [Development](#development)

## Install

```bash
pip install naget            # client and CLI
pipx install naget           # or: only the `naget` CLI, in its own environment
pip install 'naget[keyring]' # store CLI API keys in the OS keyring
```

Create an API key under **API Keys** in the developer section of naget.com
and export it as `NAGET_API_KEY`.

Optional extras:

| Extra | Adds | Use for |
|---|---|---|
| `keyring` | `keyring` | storing CLI API keys in the OS keyring |
| `test` | `pytest`, `pytest-asyncio` | running `sdk/python/tests` |
| `codegen` | pinned `datamodel-code-generator` | regenerating `src/naget/generated/` |

Runtime dependencies are `httpx`, `pydantic` v2, `typer`, and `rich`.

## Configuration

```python
from naget import Naget

client = Naget(
    api_key="sk-...",              # else NAGET_API_KEY
    base_url="https://naget.com/v1",  # else NAGET_BASE_URL, else https://naget.com/v1
    timeout=30,                    # seconds; else NAGET_TIMEOUT, else 30
    max_retries=2,                 # retry budget for retryable requests
    http_client=None,              # inject a preconfigured httpx.Client
)
```

Resolution order, highest priority first:

| Setting | Order |
|---|---|
| API key | `api_key` argument → `NAGET_API_KEY` → `ConfigurationError` |
| Base URL | `base_url` argument → `NAGET_BASE_URL` → `https://naget.com/v1` |
| Timeout | `timeout` argument → `NAGET_TIMEOUT` → `30` |

The base URL must be an absolute HTTP(S) URL with no credentials, query, or
fragment; a trailing slash is stripped. The installed package defaults to
`https://naget.com/v1`, read from the packaged `naget/public-defaults.json`; a
source checkout has no default and raises `ConfigurationError` until one is
configured.

Both clients are context managers, and both own an `httpx` client unless you
pass your own:

```python
with Naget() as client:
    ...

async with AsyncNaget() as client:
    ...
```

`client.base_url` reports the resolved origin.

## Quickstart

Run a single-step search and read its results:

```python
from naget import Naget

with Naget() as client:
    run = client.search.create(input="Find current sources", mode="search")
    result = client.search.wait(run, timeout=600)
    for row in client.search.iter_results(result.id):
        print(row["title"], row["url"], row.get("date"))
```

`mode="search"` is the single-step path. It ranks without an LLM call: it
runs one retrieval and reranks it with a cross-encoder (only
`options["parse_query"]` and the `filtered_snippet` result field add a model
call). Without an agent it searches
the Web Index, so each row is `title`, `url` and `snippet` — the opening of the
page's indexed text — plus the `date` its date filter reads and `date_source`
(`published`, `crawled` or `indexed`). `options={"web_index": {...}}` narrows
it by tier, category, primary source, site, date and language like any
[scope](#scoping-an-agent-to-the-web-index), and a `time_filter` dates it too.
The top live web results join the index's before reranking when the scope
keeps to no tier, category, primary source or language, and has no dates or a
range that runs to today and starts at most a year back. Site and homepage
filters keep the live web results but restrict them to those sites (and
homepages). A live web row's `snippet` is the search engine's own description,
and it has no `date`.

`mode="agentic"` (its default `documents` output format) is the multi-step
path, researching across many queries. Its rows have the same shape: it filters
and ranks results by relevance and writes nothing about them, so `snippet` is
the head of the context-filtered content.

To run an agent, pass `agent_id` instead of `mode`: the agent's saved defaults
pick the mode, and passing both raises `ValueError` (the API answers `400
conflicting_parameters`). An agent saved for single-step search searches each
of its attached sources once and merges the results, reranked together: the
scope filters its Web Index (and which live web results may join), while its
own search tools get every word of the input and no filters. A row from one of
the agent's own search tools carries `source` (that search tool's name) and
`fields` (the record's own fields, in order); its `url` is a `dataset://` id
unless the record names a web address. The default Web Agent (`web`) is what a
search with `mode` and no agent runs, so pass `mode` rather than
`agent_id="web"`.

Add `result_fields=["filtered_snippet"]` for the full query-relevant extract the
context-filtering model kept from each page:

```python
with Naget() as client:
    run = client.search.create(
        input="Find current sources",
        mode="agentic",
        options={"result_fields": ["filtered_snippet"]},
    )
    for row in client.search.iter_results(client.search.wait(run).id):
        print(row["title"], row["url"], row["filtered_snippet"])
```

`output_format="structured"` plans the columns to research and fills them
row by row. Rows are named entities, or the comparable units of a question's
answer (findings, options, steps, aspects); the plan's `row_grain` says what
one row represents. Row keys follow the planned columns, which the results
page lists under `columns`; a question-shaped run also carries
`results["summary"]`, a short answer over the ranked rows that references
them inline as `[N]` (skip it with `options={"table_summary": False}`):

```python
with Naget() as client:
    run = client.search.create(
        input="AI search startups with their funding stage",
        mode="agentic",
        output_format="structured",
    )
    result = client.search.wait(run, timeout=600)
    print(result.results["columns"])  # planned from the question
    for row in client.search.iter_results(result.id):
        print(row)
```

`tier=` picks the search tier of a `structured` run: one of
`naget.SEARCH_TIERS` — `"light"` (the default), `"pro"` or `"max"` — sent as
`options["tier"]`. The tiers run the same research engine with progressively
stronger models and more research; `max` also wants every row backed by two
different pages. An unknown name raises `ValueError` before anything is sent;
a tier your account may not run raises `PermissionDeniedError` (`403
tier_not_available`). Follow-ups keep the tier, and other modes ignore it:

```python
from naget import Naget, PermissionDeniedError

with Naget() as client:
    try:
        run = client.search.create(
            input="Battery recycling plants in Europe with capacity",
            mode="agentic",
            output_format="structured",
            tier="pro",
        )
    except PermissionDeniedError as error:
        print(error.code)  # tier_not_available
```

`target_results` sizes the run, not the table. Only the top `target_results`
plus a small overflow band are filtered, ranked and column-filled; rows beyond
that are still published after them, unranked, marked `_extra: True`, with the
cells extraction produced. Row keys starting with an underscore are metadata
(`_citations`, `_categories`, `_requirements_met`, `_extra`), not columns:

```python
rows = list(client.search.iter_results(result.id))
regular = [row for row in rows if not row.get("_extra")]
extra = [row for row in rows if row.get("_extra")]
```

Plans can be reviewed before execution: `confirm_plan: False` pauses the run
after planning (a `plan_review` event carries the proposal), and
`search.confirm` applies your edits and starts execution. A `structured` plan
needs its `columns` and at least one of them in `dedup_columns`; other
proposal fields (`single_entity`, `row_grain`, `column_enrich`,
`table_summary`) pass through as keyword arguments. The proposal leaves out
the agent's own search queries; omit `queries` to run them, or pass your own
to replace them:

```python
with Naget() as client:
    run = client.search.create(
        input="AI search startups with their funding stage",
        mode="agentic",
        output_format="structured",
        options={"confirm_plan": False},
    )
    plan = next(
        event.data for event in client.search.stream(run)
        if event.event == "plan_review")
    client.search.confirm(
        run,
        columns=plan["columns"] + ["Headquarters"],
        dedup_columns=plan["dedup_columns"],
    )
    result = client.search.wait(run, timeout=600)
```

A research run started with `options={"interactive": True}` may stop to ask a
clarifying question (a `research_question` event). Answer it, or skip it, with
`search.input`; `Search.research` summarizes the research's progress:

```python
with Naget() as client:
    run = client.search.create(
        input="Compare grid-scale storage options for our site",
        mode="agentic",
        output_format="text",
        options={"interactive": True},
    )
    for event in client.search.stream(run):
        if event.event == "research_question":
            client.search.input(run, question_id=event.data["id"], answer="Northern Germany")
        elif event.event in {"complete", "error"}:
            break
```

`output_format="text"` writes a cited answer. The terminal search carries
`results["answer"]` and `results["sources"]`; the `/results` page lists the
sources with the answer text alongside:

```python
with Naget() as client:
    run = client.search.create(
        input="What is agentic retrieval?",
        mode="agentic",
        output_format="text",
        options={"answer_profile": "concise_factual"},
    )
    result = client.search.wait(run)
    print(result.results["answer"])
```

When the question names one entity ("Anthropic"), the structured planner
resolves to a single-entity profile: the search reports `single_entity=True`
and returns exactly one row whose columns are the profile fields (the name,
`Type`, `Summary`, `Images`, and type-appropriate attributes):

```python
with Naget() as client:
    run = client.search.create(
        input="Anthropic", mode="agentic", output_format="structured")
    result = client.search.wait(run)
    assert result.single_entity
    profile = next(client.search.iter_results(result.id))
    print(profile["Name"], "-", profile["Summary"])
    for column in result.results["columns"]:
        print(column, ":", profile.get(column))
```

Build and query a document search tool — it is queryable as soon as the ingest
operation succeeds, and its revision publishes automatically (visibility
alone controls who can use it):

```python
with Naget() as client:
    search_tool = client.search_tools.create(
        name="Product docs",
        kind="document",
        document={
            "schema": {
                "fields": [
                    {"name": "id", "type": "keyword"},
                    {"name": "title", "type": "text"},
                    {"name": "text", "type": "text"},
                    {"name": "published_year", "type": "int64"},
                ],
                "primary_key": "id",
            },
        },
        idempotency_key="create-product-docs-v1",
    )

    mutation = client.search_tools.documents.add(
        search_tool.id,
        documents=[{
            "id": "intro",
            "attributes": {"title": "Intro", "text": "..."},
        }],
        idempotency_key="add-intro-v1",
    )
    if mutation.kind == "schema_proposal":
        mutation = client.search_tools.documents.confirm_schema(
            mutation,
            documents=[{
                "id": "intro",
                "attributes": {"title": "Intro", "text": "..."},
            }],
            selected_fields=[
                field.name for field in mutation.new_fields if field.recommended
            ],
            idempotency_key="confirm-add-intro-v1",
        )
    operation = mutation
    client.operations.wait(operation)
    hits = client.search_tools.query(search_tool.id, plan={
        "version": 2,
        "root": {
            "type": "search",
            "source": search_tool.id,
            "query": "introduction",
            "method": "hybrid",
            "fields": [{"field": "title", "boost": 2}, "text"],
            "filter": {
                "op": "gte", "field": "published_year", "value": 2024,
            },
            "candidates": 100,
            "limit": 5,
        },
    })
```

A mutation using only declared fields returns an `Operation` after durable
admission, not after the build completes. Undeclared fields return a
`SchemaProposal` instead; review its locked existing fields and suggested new
fields, then resubmit the same documents with `confirm_schema`. Always wait on
the accepted operation before querying. An accepted add, replace or remove
operation can also carry `revision` and `revision_token`; with the token,
`wait_for_revision` holds one request (up to 30 s) until queries see that
revision:

```python
with Naget() as client:
    visibility = client.search_tools.wait_for_revision(
        search_tool.id, operation.revision_token, wait_ms=10_000)
    print(visibility.status)  # visible, pending (ask again) or failed
```

`SearchTool.building` is true while files are still being read and made
searchable; the lifecycle can already be `ready` meanwhile, because queries
keep serving the current generation. `get()` also reports `maintenance` while a
compaction or schema rebuild runs: operations submitted meanwhile are accepted
and delayed behind it.

Upload files of any format and ingest them as one durable operation:

```python
with Naget() as client:
    operation = client.search_tools.ingest_files(
        search_tool.id,
        ["catalog.csv", "prices.parquet"],
        description="Product catalog with list prices",
        max_credits="50",
    )
    print(operation.status)  # succeeded
```

Query a connector by running it once with an input:

```python
with Naget() as client:
    rows = client.search_tools.query("ds_456", input="solar inverters", limit=5)
    for row in rows["data"]:
        print(row)
```

Find search tools semantically — `q` ranks everything you can see (owned,
installed, public) by name and description and returns one ranked page:

```python
with Naget() as client:
    page = client.search_tools.list(q="customer support knowledge", limit=10)
    for search_tool in page.data:
        print(search_tool.id, search_tool.name)
```

The asynchronous client mirrors the same surface; `await` each call and use
`async for` where the synchronous client yields an iterator:

```python
from naget import AsyncNaget

async with AsyncNaget() as client:
    run = await client.search.create(input="Find current sources", mode="search")
    result = await client.search.wait(run)
    async for row in client.search.iter_results(result.id):
        print(row)
```

## Resource reference

Every method below exists on both `Naget` and `AsyncNaget` with the same name
and arguments. Keyword-only arguments are shown with `*`.

### `client.search_tools`

| Method | Returns |
|---|---|
| `list(*, cursor=None, limit=20, kind=None, q=None)` | `Page[SearchTool]` (`q` = semantic search, single ranked page) |
| `iter(*, limit=20, kind=None)` | iterator of `SearchTool` |
| `create(*, name, kind, document=None, connector=None, visibility="private", description="", idempotency_key=None)` | `SearchTool` |
| `draft_connector(description, *, example_queries=None)` | draft payload (`connector` + `summary`, or `clarifying_question`) |
| `draft_document_schema(documents, *, description=None)` | suggested `document` configuration, plus per-field keep/searchable decisions |
| `get(search_tool_id)` | `SearchTool` (with `building`, and `maintenance` while a rebuild runs) |
| `upload_files(search_tool_id, paths, *, canonical=False, concurrency=4, on_progress=None)` | `list[SearchToolBulkUploadReference]` |
| `ingest_files(search_tool_id, paths, *, description=None, canonical=False, on_conflict="reject", max_credits=None, wait=True, timeout=14400, poll_interval=2, concurrency=4, on_progress=None, idempotency_key=None)` | `Operation` (terminal with `wait`) |
| `initiate_uploads(search_tool_id, *, files)` | presigned `SearchToolUploadInitiateResponse` |
| `complete_upload(search_tool_id, upload_id)` | sealed `SearchToolUploadCompletion` |
| `inspect_bulk_schema(search_tool_id, *, uploads, expected_schema_digest, input_format="canonical-jsonl", max_credits=None, idempotency_key=None, timeout=3600, poll_interval=1)` | full-file `SearchToolBulkSchemaProposal` |
| `confirm_bulk_schema(proposal, *, uploads, selected_fields, on_conflict="reject", input_format="canonical-jsonl", max_credits=None, idempotency_key=None)` | durable `Operation` |
| `create_bulk_ingestion(search_tool_id, *, uploads, expected_schema_digest, schema_update=None, on_conflict="reject", idempotency_key=None, input_format="canonical-jsonl", max_credits=None)` | inspect `Operation` without `schema_update`, durable `Operation` with one |
| `update(search_tool_id, **payload)` | `SearchTool` |
| `inspect_schema_candidates(search_tool_id, *, documents, cursor=None, limit=100)` | `SchemaCandidatePage` |
| `list_schema_candidates(search_tool_id, *, statuses=None, cursor=None, limit=100)` | `PersistedSchemaCandidatePage` |
| `evolve_schema(search_tool_id, *, expected_schema_digest, new_fields=None, new_compound_indexes=None, new_relationships=None, idempotency_key=None, max_credits=None)` | `Operation \| SchemaEvolutionUnchanged` |
| `delete(search_tool_id, *, idempotency_key=None)` | `Operation` or `None` on `204` |
| `validate(search_tool_id)` | validation payload |
| `publish(search_tool_id)` | `SearchTool` |
| `install(search_tool_id)` / `uninstall(search_tool_id)` | `SearchTool` / `None` |
| `query(search_tool_id, *, plan=None, input=None, limit=None, retrieval_mode=None, cursor=None, max_credits=None, idempotency_key=None)` | typed rows from one `QueryPlan`, or a connector's `{"data", "usage"}` |
| `wait_for_revision(search_tool_id, revision_token, *, wait_ms=None)` | `RevisionVisibility` |
| `ground_values(search_tool_id, *, field, query, limit=20)` | snapshot-bound exact-value candidates |

`create` validates locally before sending: `kind` must be `document` or
`connector`, and exactly the matching configuration argument must be supplied
(`kind="document"` requires `document=...`).

`draft_document_schema` reads a handful of representative documents and names
the fields worth keeping, the ones worth searching, and the field that
identifies a document — returned as the `document` configuration `create`
takes. Describing the data is optional context, not a requirement, and the
suggestion is advisory: edit it before creating.

`inspect_schema_candidates` infers bounded typed declarations from caller
samples without changing the active schema. `list_schema_candidates` pages the
authoritative observations persisted during mutation admission with a protected
cursor and optional status filters. Pass selected full declarations to
`evolve_schema`. Empty changes are rejected locally; a non-empty field,
compound-index, or relationship declaration that is already active may return
the explicit `unchanged` result. `max_credits` is sent only when given.

`query` takes exactly one of `plan` or `input`, and raises `ValueError`
otherwise. `plan` runs one canonical `QueryPlan` against a document search
tool and pages with its protected `cursor`. `input` runs a published connector
once and returns `{"data": [...], "usage": {...}}`; `limit` caps its results
(default 10, at most 50). `retrieval_mode` is accepted for compatibility and
ignored: a connector chooses its own retrieval. A connector query is not
replayed by key, so it sends no `Idempotency-Key`, is never retried, and
rejects `cursor` and `idempotency_key`.

`wait_for_revision` takes the `revision_token` of an accepted add, replace or
remove operation. With `wait_ms` (0 to 30000) the server holds the request
until queries see the revision, the mutation fails, or the time is up; the
returned `status` is `visible`, `failed` (with `error`) or still `pending`.
The SDK lengthens that request's read timeout to outlast the hold.

#### Uploading and ingesting files

`ingest_files` uploads local files of any size and format and ingests them as
one durable operation. Raw files (CSV, JSON, JSONL, Parquet, XLSX, ...) are
read by the search tool's parser, generated from samples of every uploaded
file when the search tool has none yet; with `canonical=True` the files are
canonical `{id, attributes}` JSONL and no parser runs. It then:

1. inspects every uploaded file (`inspect_bulk_schema`) for a signed schema
   proposal — the server answers with an already-succeeded inspect operation
   whose `result` is the proposal;
2. confirms the ingestion (`confirm_bulk_schema`) with the newly observed
   fields the proposal recommends, under a new idempotency key, passing
   `on_conflict` and `max_credits`;
3. waits for the operation with `wait` (the default), raising
   `OperationFailedError` if it fails.

A stable `idempotency_key` keys the inspection, and the confirmation's key is
derived from it, so re-running the call with the same key replays both
requests instead of ingesting twice.

The steps are also available one by one. `upload_files` streams each file
from disk to a presigned URL (up to `concurrency` at once, calling
`on_progress(filename, sent_bytes, total_bytes)`) and seals it, returning the
upload references; `initiate_uploads` and `complete_upload` are the two calls
it makes per batch. `inspect_bulk_schema` scans every sealed byte through
bounded range reads and returns signed field suggestions; it publishes
nothing. Pass the same upload references, the same `input_format`
(`"parsed"` for raw files, `"canonical-jsonl"` otherwise) and the selected
field names to `confirm_bulk_schema`. The confirmation is one idempotent
durable operation and publishes the selected schema additions and documents
atomically; an empty selection keeps newly observed attributes raw-only. Poll
the returned operation with `client.operations.wait`, and cancel it with
`client.operations.cancel`.

```python
with Naget() as client:
    uploads = client.search_tools.upload_files(search_tool.id, ["papers.jsonl"], canonical=True)
    proposal = client.search_tools.inspect_bulk_schema(
        search_tool.id,
        uploads=uploads,
        expected_schema_digest=search_tool.configuration["schema"]["digest"],
    )
    operation = client.search_tools.confirm_bulk_schema(
        proposal,
        uploads=uploads,
        selected_fields=[field.name for field in proposal.new_fields if field.recommended],
        max_credits="20",
    )
    client.operations.wait(operation, timeout=3600)
```

Document search tools are manifest only. Their `schema` declares typed fields
and optional capabilities, compound indexes, and relationships. Text fields
default to stored BM25 and semantic indexes; numeric/date/timestamp fields
default to stored exact and range indexes; primary keys always gain an exact
index. A plan can select fields, combine structured predicates with BM25,
semantic, or hybrid retrieval, and page with its protected `cursor`. Plan keys
use the canonical snake_case wire spelling.

`draft_connector` writes a connector from a description of the API — the code,
the hosts it may reach, the secret slots it reads, and the input/output schemas
— and returns it in the shape `create(kind="connector", connector=...)` takes.
Nothing is persisted and the generated source is not executed until the
connector runs in the sandbox, so review it first; when the description leaves out something required (the endpoint, the
authentication scheme) the reply is a `clarifying_question` instead of a draft.

### `client.search_tools.parser`

The `parse(file_path)` function the `parsed` input format runs. Callers read
it but never set it: `generate` and `refine` are the only ways it changes.

| Method | Returns |
|---|---|
| `get(search_tool_id)` | `SearchToolParser` (`parse_code`); `NotFoundError` before one exists |
| `generate(search_tool_id, *, description=None)` | `SearchToolParser` written from samples of every uploaded file |
| `refine(search_tool_id, feedback, *, preview=None)` | `SearchToolParser` corrected from your feedback (and a preview's output) |
| `preview(search_tool_id, *, limit=20)` | `SearchToolParserPreview`: records, fields and errors per file |

```python
with Naget() as client:
    preview = client.search_tools.parser.preview(search_tool.id, limit=10)
    if not preview.success:
        client.search_tools.parser.refine(
            search_tool.id, "Dates are day-first; keep prices as numbers", preview=preview)
```

### `client.search_tools.documents`

| Method | Returns |
|---|---|
| `list(search_tool_id, *, cursor=None, limit=20)` | `Page[Document]` |
| `iter(search_tool_id, *, limit=20)` | iterator of `Document` |
| `get(search_tool_id, document_id)` | `Document` |
| `add(search_tool_id, *, documents, on_conflict="reject", schema_update=None, idempotency_key=None, max_credits=None)` | `Operation \| SchemaProposal` |
| `replace(search_tool_id, *, documents, schema_update=None, idempotency_key=None, max_credits=None)` | `Operation \| SchemaProposal` |
| `confirm_schema(proposal, *, documents, selected_fields, on_conflict="reject", idempotency_key=None, max_credits=None)` | `Operation` |
| `remove(search_tool_id, *, document_ids, idempotency_key=None)` | `Operation \| SchemaProposal` |

`replace` is `add` with `on_conflict="replace"`. All mutations post to
`/search-tools/{id}/mutations`. `schema_update` also accepts the explicit
`{"expected_schema_digest": ..., "new_fields": [...]}` branch; passing an
empty `new_fields` or selecting no proposed fields explicitly keeps those
attributes raw-only.

### `client.search_tools.credential_bindings`

`list(search_tool_id)`, `set(search_tool_id, *, slot, secret_id, header)`,
`delete(search_tool_id, slot)`.

### `client.queries`

| Method | Returns |
|---|---|
| `execute(*, search_tools, plan=None, sql=None, parameters=None, consistency=None, execution="auto", cursor=None, max_credits=None, idempotency_key=None)` | typed result or `TypedQuery` |
| `get(query_id)` | `TypedQuery` status |
| `results(query_id, *, cursor=None)` | snapshot-bound typed result page |
| `cancel(query_id)` | `TypedQuery` status |
| `wait(query, *, timeout=300, poll_interval=1)` | terminal `TypedQuery` |
| `iter_rows(query_id)` | iterator over durable result rows |

`execute` accepts exactly one of a canonical `QueryPlan` or read-only SQL.
Each source alias is bound explicitly to an authorized search tool and optional
revision. Use `execution="async"` for durable analytical work:

```python
query = client.queries.execute(
    search_tools=[{"source": "papers", "search_tool_id": search_tool.id}],
    sql="SELECT title FROM papers WHERE published_year >= $year LIMIT 10",
    parameters={"year": {"type": "int64", "value": 2024}},
    execution="async",
)
client.queries.wait(query)
for row in client.queries.iter_rows(query.id):
    print(row["values"])
```

### `client.agents`

| Method | Returns |
|---|---|
| `list(*, cursor=None, limit=20)` / `iter(*, limit=20)` | `Page[Agent]` / iterator |
| `list_builtin()` | `list[Agent]` |
| `create(**payload)` | `Agent` |
| `get(agent_id)` / `update(agent_id, **payload)` / `delete(agent_id)` | `Agent` / `Agent` / `None` |
| `publish(agent_id)` / `install(agent_id)` / `uninstall(agent_id)` | `Agent` / `Agent` / `None` |

`client.agents.sources` provides `list(agent_id)` (a list of `AgentSource`),
`add(agent_id, *, search_tool_id)` (the added `AgentSource`), and
`remove(agent_id, source_id)`. `search_tool_id` may also be a built-in search
tool: `"web"` (live web) or `"web_index"` (the Web Index). An `AgentSource`
reports `building` while a document source's files are still being made
searchable, and for a connector its `required_secret_slots`, the
`missing_secret_slots` you have not bound yet, `needs_credentials` and
`credential_status`. `Agent.sources` holds the same models; item access
(`source["id"]`) still works.

`list_builtin()` returns the agents Naget provides. Their ids are plain slugs
such as `"patents"`, which no saved agent can take, and `Agent.builtin` is
true; `"web"` is the default Web Agent. Pass a built-in id as `agent_id` to `client.search.create` without
installing it; `get()` accepts built-in ids too. `Agent.web_index` returns an
agent's default Web Index scope as a `WebIndexScope`, or `None`.

#### Scoping an agent to the Web Index

An agent searches the Web Index when the `web_index` search tool is attached,
within the scope in `configuration["defaults"]["web_index"]`. The scope keeps
documents whose tier is in `tiers` and whose category is in `categories`
(an empty list keeps all), optionally only primary sources. Pass a dict or a
`naget.WebIndexScope`; only the fields you set are sent.

```python
from naget import Naget, WebIndexScope

with Naget() as client:
    taxonomy = client.web_index.taxonomy()
    for category in taxonomy.categories:
        print(category.id, category.group, category.tiers, category.description)

    agent = client.agents.create(
        name="Clinical evidence",
        configuration={
            "instructions": "Answer from peer-reviewed and official sources.",
            "defaults": {"web_index": WebIndexScope(
                tiers=["tier1", "tier2"], categories=["pubmed"], primary_sources_only=True,
            )},
        },
    )
    client.agents.sources.add(agent.id, search_tool_id="web_index")

    # Override the scope for one run with options["web_index"].
    run = client.search.create(
        input="Recent trials of GLP-1 drugs for heart failure",
        agent_id=agent.id,
        options={"web_index": {"categories": ["pubmed", "clinical-trials"]}},
    )

    # Built-in agents need no setup.
    for builtin in client.agents.list_builtin():
        print(builtin.id, builtin.name, builtin.web_index)
```

The six tiers are in `naget.WEB_INDEX_TIERS`; the category list follows the
live index, so read it from `client.web_index.taxonomy()`. Unknown tiers or
categories raise `ValidationError` with `param` set to
`configuration.defaults.web_index` or `options.web_index`.

### `client.web_index`

| Method | Returns |
|---|---|
| `taxonomy()` | `WebIndexTaxonomy` (`tiers`, `categories`, `primary_sources`, `languages`) |
| `search(query, *, k=10, web_index=None, include_domains=None, exclude_domains=None, start_date=None, end_date=None, homepages_only=None, languages=None, parse_query=False)` | `WebIndexSearchResponse` (`query`, `scope`, `parsed`, `data`, ...) |
| `parse(query)` | `WebIndexParseResponse` (`query`, `web_index`, `parsed`) |

A scope also keeps documents from given sites or away from them
(`include_domains`, `exclude_domains`: a host matches its subdomains, `gov`
every `.gov` host) and dated within a range (`start_date`, `end_date`, UTC
days; a document's date is its published date, else its crawl day, else the
day it was indexed, reported per result as `date` and `date_source`), and may
keep only homepages (`homepages_only`: each site's root page) and documents
written in given languages (`languages`: ISO 639-1 codes, reported per result
as `language`; `taxonomy().languages` lists the major ones by name). The site,
date, homepage and language arguments of `search` set those scope fields.
`parse(query)` returns the search text and the sites, dates, homepages,
languages, categories and tiers the query states,
for review; `search(..., parse_query=True)` does both in one call, the fields
you pass winning over parsed ones, and reports the parse as `parsed`. On
`client.search.create(mode="search", options={"parse_query": True})` the run
reports it as `Search.search_params` and in a `search_params` event; an agent's
single-step search parses only when every source it searches is the web.

```python
with Naget() as client:
    found = client.web_index.parse("climate reports on nasa.gov from last month")
    print(found.query, found.web_index)
    result = client.web_index.search(found.query, web_index=found.web_index,
                                     exclude_domains=["blogs.nasa.gov"])
    for row in result.data:
        print(row.rank, row.url, row.date, row.date_source)
```

### `client.search`

| Method | Returns |
|---|---|
| `create(*, input, mode=None, output_format=None, agent_id=None, options=None, idempotency_key=None, tier=None)` | `Search` |
| `get(search_id)` | `Search` |
| `wait(search, *, timeout=600, poll_interval=1)` | `Search` |
| `confirm(search, *, columns=None, queries=None, dedup_columns=None, **fields)` | acknowledgement (`{"ok": True, ...}`) |
| `input(search, *, question_id, answer=None, skip=False)` | acknowledgement (`{"ok": True, ...}`) |
| `results(search_id, *, cursor=None, limit=20)` | `Page[dict]` (up to 100 rows per page) |
| `iter_results(search_id, *, limit=20)` | iterator of `dict` |
| `stream(search, *, last_event_id=None, max_reconnects=3)` | iterator of `Event` |

`create` requires exactly one of a built-in `mode=` or `agent_id=...` and
raises `ValueError` otherwise. The callable modes are in `naget.SEARCH_MODES`
(`"search"`, `"agentic"`) and the `agentic` output formats in
`naget.SEARCH_OUTPUT_FORMATS` (`"structured"`, `"text"`, `"documents"`);
`output_format` with any other mode raises `ValueError`. `tier` (one of
`naget.SEARCH_TIERS`) is the same as `options["tier"]`; passing both with
different values raises `ValueError`. Every other mode runs through a saved
agent, whose defaults choose it.
`options` carries the bounded tuning fields defined in the OpenAPI
`SearchOptions` schema (`search_depth`, `source_read_limit`, `target_results`,
`search_breadth`, `answer_profile`, `time_filter`, `result_fields`,
`confirm_plan`, `table_summary`, `interactive`, `web_index`, `parse_query`,
`max_credits`, `max_response_tokens`, `max_columns`, and so on); server caps
apply regardless of what is sent.
`agent_id` also takes a built-in agent id such as `"academic"`.

For `search` and the `documents` format, `options["result_fields"]` selects which
per-result enrichments to produce — any of `naget.SEARCH_RESULT_FIELDS`, which
is `("filtered_snippet",)`. The default selection is empty, and a field that is
not requested is absent from the result rows. `naget.RankedSearchResult`
documents the shape, including the `date`, `date_source`, `source` and
`fields` a single-step row can carry. `"categories"` and `"provenance"` are
retired: still accepted so older callers are not rejected, but they add
nothing.

### `client.operations`

| Method | Returns |
|---|---|
| `list(*, cursor=None, limit=20)` / `iter(*, limit=20)` | `Page[Operation]` / iterator |
| `get(operation_id)` | `Operation` |
| `cancel(operation_id)` | `Operation` |
| `wait(operation, *, timeout=300, poll_interval=1)` | terminal `Operation` |

`cancel` stops an operation that has not published yet, such as a schema
evolution or a bulk ingestion; one that cannot be cancelled any more raises
`ConflictError` (`409 operation_not_cancellable`).

### `client.secrets`

`list()`, `create(*, name, value)`, `rotate(secret_id, *, value)`,
`delete(secret_id)`. Secret values are write-only and never returned.

### `client.usage`

`daily(*, from_date=None, to_date=None, cursor=None, limit=100)` and
`iter_daily(...)`, returning `DailyUsage` records. `from_date`/`to_date` map to
the `from`/`to` query parameters; arguments left as `None` are not sent.

### `client.account`

| Method | Returns |
|---|---|
| `get()` | credit balance and daily quotas |
| `pricing()` | the current pricing policy |
| `usage(*, limit=30, offset=0)` | charge receipts |
| `quote(mode, *, settings=None)` | the maximum charge a request would be quoted |

See [Credits and daily quotas](#credits-and-daily-quotas).

## Pagination

List methods return a `Page[T]` with `data`, `next_cursor`, and `has_more`.
The matching `iter*` helper follows cursors until exhaustion:

```python
for search_tool in client.search_tools.iter(kind="document", limit=100):
    print(search_tool.id, search_tool.lifecycle)
```

Cursor values are opaque; do not construct or persist them as identifiers.
Query parameters left as `None` (a first page's `cursor`, an open date range)
are omitted from the request rather than sent empty.

## Waiting on asynchronous work

`operations.wait`, `queries.wait`, and `search.wait` accept either an
identifier or a returned model. When given a model they follow its returned
status/result URL, which is why passing the object through is preferred over
re-deriving a path.

| Waiter | Default timeout | Poll interval | Failure |
|---|---|---|---|
| `operations.wait` | 300 s | 1 s | `OperationFailedError` |
| `queries.wait` | 300 s | 1 s | `OperationFailedError` |
| `search.wait` | 600 s | 1 s | `OperationFailedError` |

All three raise the built-in `TimeoutError` when the deadline passes; the remote
work keeps running, so a timeout is a reason to poll again, not to resubmit.
A status URL pointing at a different origin is refused with
`ConfigurationError` rather than followed with your credentials.
`search_tools.wait_for_revision` is a single long-poll request instead: it
returns `pending` when its `wait_ms` runs out, and you call it again.

## Streaming search events

`search.stream` yields parsed SSE `Event` objects, tracks the last event ID,
and reconnects with `Last-Event-ID` after an interrupted stream:

```python
for event in client.search.stream(run):
    if event.event == "answer_chunk":
        print(event.data, end="", flush=True)
    elif event.event in {"complete", "error"}:
        break
```

The iterator returns on its own once a `complete` or `error` event has been
seen. A stream the server closes early, one whose connection drops or times
out, and a connect answered `429` or `5xx` (such as `503 stream_capacity`,
retried after its `Retry-After`, otherwise with the usual backoff) all
reconnect from the last event ID, up to `max_reconnects` (default 3) times.
After that a clean close ends the iterator, a dropped connection raises
`APIConnectionError` (`APITimeoutError` for a timeout), and an error status
raises its `APIError`. Any other error status, such as `401`, `403` or `404`,
raises at once.
Event names and payloads are documented in the
[API docs](https://naget.com/developer/docs).

## Idempotency and retries

These methods send an `Idempotency-Key`, generating a UUID when you do not
supply one, and are therefore retried:

- `search.create`
- `search_tools.create`, `search_tools.delete`
- `search_tools.documents.add` / `replace` / `remove` / `confirm_schema`
- `search_tools.create_bulk_ingestion`, `inspect_bulk_schema`,
  `confirm_bulk_schema`, and both requests `ingest_files` makes (the
  confirmation's key is derived from yours)
- `search_tools.evolve_schema`
- `search_tools.query` with `plan=` (a replay returns the original answer
  without a second charge)
- `queries.execute`

Pass a stable `idempotency_key` when a retry must survive a process restart.
Replaying a key with the same body returns the original resource; replaying it
with different content is a conflict (`ConflictError`).

A request is retried only when it is a `GET`, `HEAD`, or `OPTIONS`, or when it
carries an `Idempotency-Key`. Retries fire on connection errors, timeouts, and
`429`, `500`, `502`, `503`, `504`, up to `max_retries` additional attempts.
Backoff honors `Retry-After` (clamped to 30 s) and otherwise grows
exponentially from 0.25 s to a 4 s ceiling. `search_tools.query` with
`input=` sends no key, because a connector runs once per request and the API
does not replay it, so it is never retried. Other methods without a key —
including `agents.create`, `search.confirm`, `search.input`,
`operations.cancel`, `web_index.search`, the upload and parser calls, and
every `PATCH` — are never replayed.

## Errors

All exceptions are importable from the package root.

| Exception | Raised for |
|---|---|
| `ConfigurationError` | unusable configuration; also a `ValueError` |
| `APIError` | base class for HTTP failures |
| `AuthenticationError` | `401`: a missing, invalid or disabled API key |
| `PermissionDeniedError` | `403`, such as `tier_not_available` or `agent_not_installed`; subclass of `AuthenticationError` |
| `InsufficientCreditsError` | `402 insufficient_credits`; `details` carries `required_credits` and `available_credits` |
| `NotFoundError` | `404` |
| `ConflictError` | `409` |
| `ExpiredOperationError` | `410`; subclass of `ConflictError` |
| `ValidationError` | `400`, `422` |
| `RateLimitError` | `429` |
| `APIConnectionError` | transport failure after retries, or a search stream that kept dropping |
| `APITimeoutError` | request timeout; subclass of `APIConnectionError` |
| `OperationFailedError` | a remote operation or search reached `failed` |

`APIError` carries `status_code`, `type`, `code`, `param`, `request_id`,
`details` (the envelope's `error.details`, such as the required credits or the
issues behind `409 agent_dependencies_unavailable`; empty when there are
none), and the parsed `body`. `OperationFailedError` also carries
`.operation`, the terminal model. Because `PermissionDeniedError` subclasses
`AuthenticationError`, an existing `except AuthenticationError` still catches
`403`. Quote `request_id` when reporting a failure:

```python
from naget import APIError, InsufficientCreditsError, Naget

try:
    with Naget() as client:
        client.search_tools.get("ds_missing")
except InsufficientCreditsError as error:
    print("needs", error.details.get("required_credits"), "credits")
except APIError as error:
    print(error.status_code, error.code, error.request_id)
```

## Models

Responses are parsed into pydantic models — `SearchTool`, `Document`, `Agent`
(with `AgentSource`), `Operation`, `RevisionVisibility`, `TypedQuery`,
`Search`, `Secret`, `DailyUsage`, `WebIndexTaxonomy` (with `WebIndexTier`,
`WebIndexCategory` and `WebIndexLanguage`), `Event`, and `Page[T]`.
`RankedSearchResult` documents a `search` or `documents` result row, and
`SearchParams` what a parsed single-step search searched.
`WebIndexScope` is also accepted in request payloads. They
allow extra fields, so a server that adds a property does not break an older
client; unknown values stay reachable through the model instance. `Operation`,
`TypedQuery`, and `Search` expose a `terminal` property.

Fields worth knowing: `SearchTool.building`, `SearchTool.maintenance` and
`SearchTool.owner_id`; `Operation.revision` and `Operation.revision_token`;
`Search.search_params` and `Search.research`.

For exhaustive request/response schemas generated straight from the contract,
use the wire models:

```python
from naget import wire_models
```

## Escape hatch

`client.raw.request(method, path, **kwargs)` returns an authenticated
`httpx.Response` while keeping the SDK's URL resolution, retry policy,
timeout, and error handling. Use it for a route the typed layer does not cover
yet, not to bypass the transport rules.

## CLI

The wheel installs a `naget` entry point.

```text
naget context list|create|use|show|delete
naget defaults public-origin
naget search run|get|watch|results
naget agents list|builtin|create|get|update|delete|publish|install|uninstall
naget agents sources list|add|remove
naget web-index taxonomy|search|parse
naget search-tools list|create|draft-schema|draft-connector|get|update|delete|validate|publish|install|uninstall|query|ingest|import
naget search-tools parser get|generate|refine|preview
naget search-tools documents list|get|add|replace|remove
naget search-tools credential-bindings list|set|remove
naget operations list|get|wait|cancel
naget secrets list|create|rotate|delete
naget usage daily
```

`naget COMMAND --help` is the exact option reference.

### Global options

Global options come **before** the command group:

| Option | Effect |
|---|---|
| `--json` | stable machine-readable output instead of tables |
| `--quiet`, `-q` | suppress the "No results." notice |
| `--context NAME` | select a named context for this invocation |
| `--base-url URL` | override the resolved base URL |
| `--timeout SECONDS` | per-request timeout (default 30) |
| `--api-key-env NAME` | read the key from a different environment variable |

```bash
naget --json search-tools list --kind document
```

### Contexts and credentials

Named contexts live in `$XDG_CONFIG_HOME/naget/config.toml` (by default
`~/.config/naget/config.toml`), written with `0600` permissions. On first use
the file is seeded from the default, `https://naget.com/v1`.

```bash
naget context create naget --base-url https://naget.com/v1
naget context use naget
naget context show
```

Only the base URL is stored. API keys come from `NAGET_API_KEY` (or the
variable named by `--api-key-env`), falling back to the OS keyring under
service `naget` with the context name as the entry — install the `keyring`
extra to use it. Keys are never written to the context file.

`naget defaults public-origin` prints the scheme and host of the resolved
base URL (`https://naget.com` for the installed package).

### Common flows

```bash
# Search
naget search run --mode search --input "Question" --wait
naget search run --mode agentic --output-format structured --input "Question" --wait
naget search run --mode agentic --output-format structured --tier pro --input "Question" --wait
naget search run --agent agt_123 --input "Question" --watch
naget search watch SEARCH_ID --last-event-id EVENT_ID
naget search results SEARCH_ID --limit 50
naget --json search results SEARCH_ID --all

# Agents scoped to the Web Index
naget web-index taxonomy                      # tiers, categories and languages to choose from
naget web-index search "solar inverter recalls" --domain cpsc.gov --from 2026-01-01
naget web-index search "solar panel makers" --homepages-only
naget web-index search "energiewende" --language de
naget web-index parse "climate reports on nasa.gov from last month"
naget web-index search "climate reports on nasa.gov from last month" --parse
naget agents builtin                          # agents Naget provides
naget search run --agent academic --input "Question" --wait
naget agents create --name "Clinical evidence" --instructions-file prompt.md \
  --tier tier1 --tier tier2 --category pubmed --primary-sources-only
naget agents create --name "Whole index" --instructions-file prompt.md --web-index

# SearchTool lifecycle
naget search-tools list --q "customer support knowledge"
naget search-tools create --kind document --name "Docs" \
  --primary-key id --searchable-field title --searchable-field text
naget search-tools documents add ds_123 --file docs.jsonl --on-conflict reject --wait
naget search-tools validate ds_123
naget search-tools publish ds_123
naget search-tools query ds_123 --plan query-plan.json

# Files of any format: parsed, inspected and ingested as one operation
naget search-tools ingest ds_123 catalog.csv prices.parquet \
  --description "Product catalog with list prices" --max-credits 50
naget search-tools parser preview ds_123 --limit 10
naget search-tools parser refine ds_123 "Dates are day-first"

# Supply the complete typed schema for numeric, geo, array, or compound indexes
naget search-tools create --kind document --name "Papers" --schema schema.json

# Connectors
naget secrets create --name company-token --from-stdin
naget search-tools create --kind connector --name "Company API" \
  --code connector.py --entrypoint search \
  --allow-host api.example.com --secret-slot api_token
naget search-tools query ds_456 --input "solar inverters" --limit 5

# Let the schema be suggested from a sample instead of naming fields by hand
naget search-tools draft-schema --sample records.jsonl
naget search-tools create --kind document --name "Docs" --sample records.jsonl

# ...or describe the API and let the connector be written for you
naget search-tools draft-connector "Search https://api.example.com/search?q=<q>, \
  bearer token in Authorization, results in data[] with title/url/snippet"
naget search-tools create --kind connector --name "Company API" \
  --describe "Search https://api.example.com/search?q=<q>, bearer token in \
  Authorization, results in data[] with title/url/snippet"
naget search-tools credential-bindings set ds_456 \
  --slot api_token --secret sec_123 --header X-API-Key

# Operations
naget operations wait OPERATION_ID --timeout 600
naget operations cancel OPERATION_ID
```

`agents create` stores `--tier`, `--category` and `--primary-sources-only` as
the agent's `defaults.web_index` scope and then attaches the built-in
`web_index` search tool, without which the scope has no effect. Any of these
options implies `--web-index`; `--web-index` alone attaches the tool with no
scope, so the agent searches the whole index. Add `web` with
`naget agents sources add AGENT_ID --search-tool web` to search the live web
too. (`search run --tier` is different: it picks the search tier of a
structured run.)

`search results` prints one page (`--limit`, default 20, at most 100;
`--cursor` for a later page); `--all` follows the cursors and prints every
row. `search-tools query` takes `--plan` for a document search tool or
`--input` (with `--limit`) for a connector.

`--wait` on a mutation blocks until the operation is terminal. Destructive
commands prompt unless `--yes` is given. `secrets create`/`rotate` read the
value from stdin with `--from-stdin`, otherwise prompt without echo.

### Bulk document loading

`search-tools ingest` uploads files of any format and size (5 GiB each)
straight to storage and ingests them as one durable operation, generating the
parser first when the search tool has none; `--canonical-jsonl` skips the
parser for `{id, attributes}` JSONL.

`documents add` and `documents replace` read a JSONL file in one request and
refuse it above the direct-mutation bounds: 1,000 documents or 10 MiB of
canonical JSON.

`search-tools import` streams a larger JSONL file into a sequence of bounded
mutations that respect the same limits. Each batch derives its idempotency key
deterministically from the search tool id, the batch number, and the canonical
batch bytes, so re-running an interrupted import replays rather than
duplicates. It is a compatibility helper, not a resumable bundle uploader.

```bash
naget search-tools import ds_123 corpus.jsonl --on-conflict reject --wait
```

### Exit codes

| Code | Meaning |
|---|---|
| `0` | success |
| `2` | invalid input or a `400`/`422` response |
| `3` | authentication (`401`) or permission (`403`) failure |
| `4` | conflict, including an expired operation |
| `5` | remote operation failure or another API error |
| `6` | connection failure or timeout |
| `7` | insufficient credits (`402`) |

Errors print to stderr with the `request_id` when the response carried one.

## Development

From a checkout of the Naget repository:

```bash
python -m pip install -e './sdk/python[test,codegen]'
pytest sdk/python/tests -q
```

Regenerate the wire models after an OpenAPI change, then verify that the
committed output matches a fresh generation:

```bash
sdk/python/scripts/generate_models.sh
sdk/check-generated.sh
```

`src/naget/generated/` is machine-produced; edit `openapi/naget-v1.yaml` and
regenerate instead of patching it. `sdk/fixtures/wire-goldens.json` pins the
exact request bodies this client must emit and is shared with the TypeScript
suite.

### Generated versus source files

- `src/naget/` and the OpenAPI contract are source controlled.
- `sdk/public-defaults.json` is rendered by deployment tooling and is not
  source.
- `src/naget/public-defaults.json`, wheels, and `dist/` are generated release
  artifacts and must not be committed.

Build a release wheel only after rendering deployment defaults:

```bash
sdk/python/scripts/build_release.sh
```

The script fails closed when `sdk/public-defaults.json` is absent or does not
describe a safe HTTP(S) origin.

## Related documentation

- [Naget API docs](https://naget.com/developer/docs): routes, search modes, search tools, events
- [TypeScript SDK](https://www.npmjs.com/package/@naget/sdk) (`@naget/sdk`)

### Credits and daily quotas

```python
account = client.account.get()
pricing = client.account.pricing()
quote = client.account.quote("text", settings={"max_response_tokens": 2000})
receipts = client.account.usage(limit=30)
```

Money is returned as decimal strings; $1 equals 1,000 credits. Signed-in
accounts receive 2,000 free credits per UTC day, spent before any granted
credits. A cap must cover the entire bounded quote: a lower one is refused
before any work starts (`ValidationError`, `400 credit_cap_too_low`, with
`details["required_credits"]`). `max_credits` (a decimal string) caps the
charge of:

- a search: `options={"max_credits": ...}` on `search.create`, alongside
  `max_response_tokens` and `max_columns`;
- document mutations: `documents.add`, `replace` and `confirm_schema`;
- file ingestion: `ingest_files`, `confirm_bulk_schema` and
  `create_bulk_ingestion` (`inspect_bulk_schema` accepts one too, though an
  inspection is not charged);
- schema evolution: `evolve_schema`;
- native queries: `search_tools.query` and `queries.execute`.

Retain `idempotency_key` when retrying a logical operation. When neither the
free allowance nor your balance covers a quote, the call raises
`InsufficientCreditsError` (`402 insufficient_credits`) with
`details["required_credits"]` and `details["available_credits"]`; `429`
includes `Retry-After`. `client.account.pricing()` returns the current rates.

Search budgets are managed internally from the planned answer size and evidence needs.
Use `search_depth` and `search_breadth` to control candidates per query and parallel queries per step.
