Metadata-Version: 2.5
Name: hotdata-langchain
Version: 0.13.0
Summary: LangChain tools for Hotdata runtime
License: MIT
Requires-Python: >=3.10
Requires-Dist: hotdata-framework>=0.10.0
Requires-Dist: hotdata>=0.8.0
Requires-Dist: langchain-core>=1.0
Requires-Dist: numpy>=1.26
Requires-Dist: pyarrow>=15
Provides-Extra: agents
Requires-Dist: langchain>=1.0; extra == 'agents'
Description-Content-Type: text/markdown

# hotdata-langchain

Connect [LangChain](https://python.langchain.com/) to [Hotdata](https://hotdata.dev) — tools that let an agent run SQL against your workspace connections, search indexed columns by text relevance or by meaning, and work with instant databases, plus a `VectorStore` implementation so Hotdata can back any LangChain retriever or chain.

## Install

```bash
pip install hotdata-langchain
```

To run the agent quickstart below, install the `agents` extra as well — it adds the
`langchain` package that `create_agent` lives in:

```bash
pip install "hotdata-langchain[agents]"
```

## Authentication

Set `HOTDATA_API_KEY` in your environment. Optionally set `HOTDATA_WORKSPACE` to pin a specific workspace (the first available workspace is used if unset).

## Quickstart

Of the LangChain packages, this one needs only `langchain-core`, and works with any
tool-calling model. Running an agent additionally needs the `langchain` package — the
`agents` extra above — plus the integration for whichever model provider you use; using
`HotdataVectorStore` needs an embedding provider's integration, such as `langchain-openai`.

```python
from langchain.agents import create_agent
import hotdata_langchain as hl

client = hl.from_env()
tools = hl.make_hotdata_tools(client, database_id="dbid...")

agent = create_agent(model=your_model, tools=tools)
result = agent.invoke(
    {"messages": [{"role": "user", "content": "Which product categories have the most orders?"}]}
)
print(result["messages"][-1].content)
```

Queries run against a database scope, so pass `database_id=` (an instant database id).
`hl.from_env().list_managed_databases()` shows what is available in the workspace, with the
id of each.

## Tools

`make_hotdata_tools(client)` returns a list of LangChain `StructuredTool` objects ready to pass to any agent:

| Tool | What it does |
|------|-------------|
| `hotdata_execute_sql` | Run a SQL query and return rows as JSON |
| `hotdata_list_managed_databases` | List available instant databases, with the id of each |
| `hotdata_create_managed_database` | Create a new instant database and return its id |
| `hotdata_load_managed_table` | Load a parquet file — local path or URL — into a managed table, addressed by database id |
| `hotdata_describe_tables` | List tables, or one table's columns, types and how many rows hold a value |
| `hotdata_search_text` | Search an indexed column by text relevance, fused with meaning where the table supports it (opt-in — see below) |
| `hotdata_search_semantic` | Search an indexed column by meaning; replaces the above when the column carries a vector index |

The descriptions carry the engine's contract — dialect, what SQL can and cannot do, and where
to look things up — so an agent does not need a system prompt explaining the query engine.

Every name in that table is exported as a constant (`hl.DEFAULT_SQL_TOOL_NAME` and so on), so
an application selecting a subset does not have to hardcode strings.

## Choosing which tools an agent gets

An agent that reads one fixed database cannot use the three instant-database tools, and giving
it tools it can only misuse costs context on every turn. Two flags decide the set:

```python
tools = hl.make_hotdata_tools(client, database_id="dbid...")                        # all of them
tools = hl.make_hotdata_tools(client, database_id="dbid...", management_tools=False)  # SQL + describe
tools = hl.make_hotdata_tools(client, database_id="dbid...", describe_tables=False)
```

`management_tools=False` drops listing, creating and loading. It is not called `read_only`:
listing databases is itself a read, so the set it removes is the instant-database workflow
rather than everything that writes.

## Letting the model recover from a failed call

The tools raise on failure, which is right in a script and wrong in an agent: an exception out
of a tool aborts the whole LangGraph run, so one invalid query ends the conversation instead of
costing a turn. `handle_errors=True` returns the failure as `{"error": "..."}` instead:

```python
tools = hl.make_hotdata_tools(client, database_id="dbid...", handle_errors=True)
```

What reaches the model is the engine's own message, not the framework's `RuntimeError("Bad
Request")` — `Invalid function 'date_sub'. Did you mean 'date_bin'?` is something a model can
act on, and was observed correcting its query on the next call. The exception is a failure this
package can explain better than the engine can, such as a [date format
pattern](#warnings-about-results-that-are-not-what-they-look-like) that will not be
interpreted: that message leads, with the engine's after it. `hl.engine_error_message(exc)`
exposes that lookup on its own, and `hl.with_error_feedback(tools)` applies the same wrapping to
tools built elsewhere, such as a retriever tool registered alongside these:

```python
from langchain_core.tools import create_retriever_tool

tools = hl.with_error_feedback([*tools, create_retriever_tool(retriever, "search_docs", "...")])
```

Both `func` and `coroutine` are wrapped. Wrapping only the sync callable is a trap: LangChain
prefers `coroutine` under async, which is how `langgraph dev` and a deployed Agent Server run,
so a sync-only wrapper goes unused in exactly the environment that needs it.

Only failures are touched. A successful result comes back exactly as the tool produced it, so a
tool declaring `response_format="content_and_artifact"` keeps its pair intact, and LangGraph's
control-flow exceptions are re-raised — a tool calling `interrupt()` for human approval still
pauses the graph rather than reporting the pause as an error.

## Letting the agent discover the schema

`hotdata_describe_tables` is registered by default. Called with no arguments it lists every
table with its column count; called with a table name it returns that table's columns and
types. Without it an agent has to guess column names, and a guess that misses fails the query.

It takes a table as `listings`, `public.listings` or the full `catalog.schema.table`, each part
narrowing the lookup, in whatever case the reference is written. The full form is accepted
because the SQL tool's description tells the model to address tables with all three parts, and
both descriptions reach it in one prompt — so a model that follows one must not be corrected by
the other. The worked example in the description names a catalog only where the database
exposes exactly one, since `default` is right for an instant database and wrong for an attached
source.

```python
tools = hl.make_hotdata_tools(client, database_id="dbid...")            # included
tools = hl.make_hotdata_tools(client, database_id="dbid...", describe_tables=False)  # omitted
```

It reads `information_schema` in whichever database the tools are scoped to. With it turned
off, the SQL tool's description tells the agent to query `information_schema` directly instead.

Each column also reports `non_null`, how many of the table's rows hold a value in it,
alongside the table's `row_count`:

```json
{
  "table": "public.listings",
  "row_count": 7535,
  "columns": [
    {"name": "id", "type": "Int64", "non_null": 7535},
    {"name": "price", "type": "Float64", "non_null": 0}
  ]
}
```

A type alone says a column exists, not that anything is in it — an agent given only types was
measured recommending an analysis of a column that is NULL on every row. The counts cost one
aggregate query per table described; pass `describe_column_stats=False` where describing a
table must not scan it.

A table that is declared on the database but has never been loaded has no columns at all in
`information_schema`, which reads as a missing table. It is reported as declared and empty
instead, which is the state a load fixes.

### Which columns can be searched

Indexes are invisible to SQL — there is no `pg_indexes` and no `information_schema.indexes` — so
without asking the control plane, an agent cannot tell a searchable column from any other text
column. Describing one table reports what each column can be searched by:

```json
{
  "table": "public.listing_corpus",
  "columns": [
    {"name": "content", "type": "Utf8View", "searchable_by": ["text relevance"]},
    {"name": "embedding", "type": "List(Float32)", "searchable_by": ["meaning"]},
    {"name": "rating", "type": "Float64"}
  ]
}
```

The capability is named, not the index behind it: `text relevance` matches the words a value
uses, `meaning` matches what it is about. Only indexes the engine reports as ready are named —
a search against one still building fails, and it fails after the model has committed to that
route.

Where a vector index was built by an embedding provider over a text column, the engine
materialises a vector column beside it — building one over `content` produces
`content_embedding`. That column is real in `information_schema` and is not the agent's to
query, so it is left out of the description and the text column carries the `meaning`
capability instead.

This costs one control-plane call per table described, and only on the per-table call: the
no-argument listing stays index-free, so a wide database is not N calls. Pass
`describe_search_capabilities=False` to turn it off, which also stops the generated vector
column being filtered, since nothing then knows it was generated.

## Calling tools directly

You can also invoke tools outside of an agent loop:

```python
import json

tools = {t.name: t for t in hl.make_hotdata_tools(client, database_id="dbid...")}

result = tools["hotdata_execute_sql"].invoke({"sql": "SELECT * FROM orders LIMIT 10"})
print(result)  # JSON rows

created = tools["hotdata_create_managed_database"].invoke({
    "name": "sales",            # a display label, not an identifier
    "schema_name": "public",
    "tables": "orders,customers",
})

tools["hotdata_load_managed_table"].invoke({
    "database_id": json.loads(created)["id"],
    "table": "orders",
    "file": "/path/to/orders.parquet",   # or "https://example.com/orders.parquet"
})
```

A URL is downloaded and uploaded for you, and the temporary copy is removed afterwards whether
or not the load succeeds. This is the form that works from a deployed agent: an Agent Server
has no filesystem the requesting user can put a file on, so a path-only load can ingest nothing
the process did not already hold. Only parquet is accepted, and a URL that answers 200 with an
HTML login or error page is rejected before anything is uploaded.

Two limits apply, because the URL is chosen by the model and a model's inputs include whatever
text it retrieved — an instruction planted in a document is enough to pick one:

- **It must resolve to a public address.** Otherwise the agent process becomes a fetcher for
  whatever its own network can see, which in a deployment is usually more than the public
  internet: a cloud metadata endpoint on `169.254.169.254`, an internal service on a private
  range. A load completes the loop, since an internal URL serving parquet would land in the
  workspace and be readable from SQL on the next turn. Every address the host resolves to is
  checked, and again on each redirect — a public URL that 302s inwards is the standard bypass.
  Pass `allow_private_hosts=True` to `make_hotdata_tools` when your data genuinely sits on an
  internal host.
- **It is capped at 1 GiB.** `Content-Length` is checked first, so an oversized file is usually
  refused before any of it transfers, and the stream is counted as well because that header is
  optional and can lie. `hl.databases.fetch_parquet(..., max_bytes=...)` raises the cap.

This narrows what the fetch can reach rather than sealing it: the address is resolved for the
check and again by the HTTP client, so a DNS server answering differently each time can still
get through. A deployment on an untrusted network wants an egress proxy in front of this.

## Search

Point the agent at an indexed column and it gets a search tool alongside SQL:

```python
tools = hl.make_hotdata_tools(
    client,
    database_id="dbid...",
    search_table="default.public.listings",   # catalog.schema.table
    search_column="description",              # must carry a search index
    search_columns=["id", "name", "price", "description"],  # what each hit returns
    search_k=5,
)

hits = {t.name: t for t in tools}["hotdata_search_text"].invoke(
    {"query": "cozy apartment with a view"}
)
```

Rows come back ranked, each with a `score`. The agent supplies only `query` and an optional
`k`; the table and column are fixed when you build the tool. That is deliberate: the engine
errors outright rather than falling back to a scan when a column has no BM25 index, so a
model choosing its own corpus can pick one that cannot answer. `hotdata_describe_tables` now
reports which columns are searchable, which is what a model would need to choose from
something it read rather than guessed — the search tool has not been changed to accept that
choice yet.

### Text or meaning, decided by the index

Which kind of search you get is read off the column's indexes when the tools are built,
because indexes are invisible to SQL and the control plane is the only thing that can answer.
A BM25 index means text relevance; a vector index means closeness in meaning. You get one
tool either way — a model asked to choose between two search tools is being handed an
implementation detail as a decision.

| Index on the column | Tool | Ranking column | Needs an `Embeddings` |
|---|---|---|---|
| BM25 | `hotdata_search_text` | `score`, highest first | no |
| BM25, with a plain vector index on the table | `hotdata_search_text`, fused | `score`, highest first | **yes**, pass `search_embedding=` |
| vector, provider-backed | `hotdata_search_semantic` | `_distance`, lowest first | no — the engine embeds the query |
| vector, plain | `hotdata_search_semantic` | `_distance`, lowest first | **yes**, pass `search_embedding=` |

A *provider-backed* index is one built over a text column with an embedding provider: the
engine embeds the column and the query, so nothing is needed on this side. A *plain* index is
built over a column that already holds vectors, and the engine has no record of how they were
produced — so the query must be embedded here, with the same model the column was written
with:

```python
tools = hl.make_hotdata_tools(
    client,
    database_id="dbid...",
    search_table="default.public.listing_corpus",
    search_column="embedding",
    search_columns=["id", "content"],   # the vector column is never returned
    search_embedding=OpenAIEmbeddings(model="text-embedding-3-small"),
)
```

`_distance` keeps the engine's own name, underscore included, rather than being tidied to
`distance` in results. It is the only name that works in SQL the agent writes itself, and one
value with two names across the two descriptions is a translation step with nothing to gain.
Lower is nearer, which is the reverse of `score` — both descriptions say so, because a model
that assumes higher-is-better reads the ranking backwards.

Passing `search_embedding` for a plain index is not optional: without it the tools refuse to
build, rather than failing on the agent's first query with a type error it cannot act on.
`search_strategy="semantic"` forces that route and raises if no vector index covers the
column, and `search_strategy="hybrid"` does the same for a fused one. `search_strategy="text"`
forces the text route but does *not* raise, because index
introspection fails open — a workspace that cannot list indexes would otherwise stop being
able to build a search tool over a column that really does carry a BM25 index. There the
engine's own "No BM25 index found" is the error, at the same point it was before.

The two never collide on one column. BM25 indexes a text column and a plain vector index a
vector column, so they land on different columns of a table; and a provider-backed vector
index cannot share a table with any other index at all. See
[docs/engine-contract.md](docs/engine-contract.md) for what that restriction costs. Landing on
different columns of one table is also what lets both be used at once — see
[Both at once, under one tool](#both-at-once-under-one-tool) below.

The SQL tool's description follows the same route, so it names `vector_search` where the
column is searchable by meaning and `bm25_search` where it is searchable by text. Both
descriptions reach the model in one prompt, and pointing it at a function with no index on
the column it was just handed is a failure no test would catch.

`search_columns` is optional. Left unset, a hit carries the searched column plus the table's
`id`, so it can be joined back to the table it came from — a hit holding only the matched text
references nothing. The key column is looked up once when the tools are built and dropped if
the table has no such column; `search_key_column="listing_id"` names a different one, and
`search_key_column=None` returns the searched column alone.

`k` is governed by `max_rows`: a larger `k` from the model is cut to it *before* the search
runs, so those rows are never ranked at all. The ceiling is stated in the tool's description
and in its `k` argument, and a call that was cut says so in `metadata.client_warning`. To
reason over a wider cohort, call `bm25_search` (or `vector_search`, on a semantic route)
inside SQL and aggregate there. A plain vector index has no such composed form — writing it
needs a query vector, which SQL cannot express — and the SQL tool's description says so
rather than advertising a route the agent cannot take. On a fused route only the text half
is expressible in SQL, so the composed form still holds a whole cohort in one query but
ranks on wording alone; both descriptions say that too, rather than one of them claiming
SQL does what the tool does.

An instant database's tables read as `default.<schema>.<table>`. An **attached** source's do
not — its tables answer to the attachment's alias, and `default.<schema>.<table>` is not
found there. Nothing on the database record distinguishes the two, so there is no constant
the tools can assume.

`make_hotdata_tools` therefore reads the catalog from `information_schema` once, when the
tools are built, and states it in the SQL tool's description — so the model is told the real
catalog rather than a rule that holds for only one kind of database. Pass `catalog="…"` to
skip that lookup.

Write all three parts either way: a two-part `schema.table` reference resolves and returns the
same rows, but the engine matches its index lookup on the reference as written, so the short
form can quietly forfeit an index. `HotdataVectorStore` and the search tool emit the full form
themselves.

For more than one searchable corpus, build the tools yourself and give each a distinct name
and description — the agent then routes on the descriptions:

```python
tools = [
    # Configure the first corpus here, so the SQL tool's description still names a search
    # tool to defer text matching to. Passing no search_table/search_column drops that,
    # and the agent goes back to trying to match text in SQL.
    *hl.make_hotdata_tools(
        client,
        database_id="dbid...",
        search_table="default.public.listings",
        search_column="description",
        search_tool_name="search_listings",
    ),
    hl.make_hotdata_search_tool(
        client, table="default.public.reviews", column="comments",
        name="search_reviews", database_id="dbid...",
    ),
]
```

Provisioning the index itself is not yet part of this package; create it through the Hotdata
API or CLI. `demo/` has a script that does the whole flow — instant database, data load, BM25
index, then an agent that picks between search and SQL.

### Both at once, under one tool

Because BM25 and a plain vector index land on different columns of the same table, both can
be used at once. Pass `search_embedding=` alongside a BM25 column and the text tool ranks by
wording *and* by meaning, merging the two rankings with reciprocal rank fusion:

```python
tools = hl.make_hotdata_tools(
    client,
    database_id="dbid...",
    search_table="default.public.listing_corpus",
    search_column="content",            # the BM25 column, as before
    search_embedding=OpenAIEmbeddings(model="text-embedding-3-small"),
)
```

The tool keeps its name, its `score` column and its contract. There is no second tool and no
flag for the model to set, because the two fail differently and cover each other: BM25 misses
a paraphrase that shares no words with the query, while vector search misses rare exact tokens
— ids, model numbers, proper nouns — that embeddings smear. Making the model pick between them
is handing it an implementation detail as a decision, and picking wrong costs recall silently.

Ranks are fused, never scores. BM25 scores are unbounded and run around 8 to 11; cosine
distance runs 0 to 2 and points the other way. There is no scale on which those can be added,
so only rank position crosses between them. A row scores `1 / (60 + rank)` in each pathway
that returned it, and each pathway looks `max(4k, 20)` deep so the lists overlap enough to
have something to agree about. It is one query, not two — [the SQL is in
docs/engine-contract.md](docs/engine-contract.md) — so fusion costs one round trip rather than
two, and `hybrid_search_sql` exposes the constant and the depth if you want to tune them.

Fusion applies only where the engine allows both indexes on one table, which rules out a
provider-backed vector index. It needs both halves actually present — a BM25 index on the
searched column that has finished building, and a plain vector index to pair with it — plus a
key column to join the two rankings on: the table's `id`, or whatever `search_key_column=`
names. Since the engine records no link between a vector column and the text it was derived
from, a table carrying more than one plain vector index needs `search_semantic_column=` to say
which pairs with the text column; with exactly one, it is inferred.

When any of that is missing, `search_strategy="auto"` keeps the search the table can support
and logs why it did not fuse, at `INFO` on the `hotdata_langchain.search` logger — so a search
that is quietly not fused has somewhere to say so. `search_strategy="hybrid"` raises there
instead, which is the reason to ask for it by name. `search_strategy="text"` opts back out to
plain BM25 on a table that could be fused.

## Vector store

`HotdataVectorStore` implements LangChain's `VectorStore`, so Hotdata works as the retrieval
backend for any retriever, chain or eval built on that interface.

It is a primitive rather than a tool: it is not part of `make_hotdata_tools`, and a model cannot
call it directly because it has no name, description or argument schema. You compose it into a
chain, hand `as_retriever()` to anything expecting a retriever, or wrap it as a tool so an agent
*can* call it — see [below](#letting-an-agent-search-the-store).

```python
from langchain_openai import OpenAIEmbeddings

store = hl.HotdataVectorStore(
    client,
    OpenAIEmbeddings(model="text-embedding-3-small"),
    database_id="dbid...",
    table="documents",
)

store.add_texts(
    ["Cozy studio with great light", "Two-bedroom near the park"],
    [{"city": "sf"}, {"city": "nyc"}],
)

docs = store.similarity_search("somewhere bright to stay", k=3)
retriever = store.as_retriever(search_kwargs={"k": 3})   # composes into any chain
```

Rows are stored in one managed table keyed on `id`, so re-adding a document with an existing
id replaces it rather than duplicating it. `delete(ids=[...])` requires ids — there is no
delete-everything call.

The store declares that table itself. If you pre-create the database, leave the table out of
`tables=[...]` and let the store declare it, or declare it with `key=["id"]` yourself — a
managed table with no key takes writes as appends, so re-adding a document would duplicate it,
and an existing table's key cannot be read back to warn you.

Searches run as a single SQL query using the engine's scalar distance functions:

```sql
SELECT id, content, metadata_json,
       cosine_distance(embedding, ARRAY[...]) AS dist
FROM "default"."public"."documents"
ORDER BY dist ASC
LIMIT 4
```

That query is correct with **no index at all** — it brute-forces the table — so a store is
usable the moment you create it, before any indexing exists.

Once a vector index built on the same metric exists on the embedding column, the engine
rewrites that identical query into an index lookup, with nothing in your code changing. This
is confirmed against a live engine: the query plan switches to a `USearchExec` node, and a
`WHERE` filter is pushed *into* the index lookup rather than costing you the fast path. See
[`docs/engine-contract.md`](docs/engine-contract.md) for the observed plans.

Three things forfeit the rewrite and fall back to a full scan, silently and without error:
projecting the raw `embedding` column, querying with a distance function the index was not
built for, and omitting `LIMIT`. Similarity search does none of them; MMR
[does the first](#diverse-results-with-mmr), by necessity.

The store builds that index for you:

```python
store.create_index()                       # or, in one step:
store = hl.HotdataVectorStore.from_texts(
    texts, embeddings, client=client, database_id="dbid...", create_index=True
)
```

Build it *after* the first write. The engine reads the vector width off stored data, so there
is nothing to measure before then. The metric always comes from this store's `distance`, which
is what earns the rewrite — leaving it to the server would build an `l2` index, its default,
that never serves a `cosine` search. Calling `create_index()` when a matching index already
exists does nothing and returns `None`, so it is safe on every start-up; an index that already
exists under a *different* metric raises, since only you know whether the index or the
`distance=` is the mistake.

Builds are polled to completion, up to `timeout_s=900`. Pass `wait=False` to return as soon as
the build is accepted and check the job yourself.

`distance=` accepts `"cosine"` (default), `"l2"` and `"dot"`. Prefer `cosine`: its relevance
score is exact, whereas the engine's `l2_distance` is *squared* L2 and LangChain's Euclidean
relevance score expects true Euclidean distance, so `similarity_search_with_relevance_scores`
under `l2` returns scores on the wrong scale. Ranking is correct under all three.

### Diverse results with MMR

The `k` nearest documents are often near-duplicates of each other — all genuinely close to
the query, all making the same point. Maximal marginal relevance ranks a wider pool by
distance, then picks `k` from it one at a time, scoring each candidate against both the query
and what it has already picked:

```python
docs = store.max_marginal_relevance_search("somewhere bright to stay", k=3, fetch_k=20)

retriever = store.as_retriever(search_type="mmr", search_kwargs={"k": 3, "fetch_k": 20})
```

`lambda_mult` is the balance: `1.0` is pure relevance, `0.0` is pure variety. `fetch_k` is the
candidate pool, and is raised to `k` if you pass less. `filter=` works the same as it does on
`similarity_search`. Results come back in selection order — only the first is the nearest to
the query, and a later pick is often further away than one it was chosen over.

This is the one search that reads the stored vectors, which is what MMR needs and what
forfeits the index lookup — the candidate fetch is a full scan even where an index exists,
bounded by `fetch_k`. Use it where variety in the retrieved set matters more than the cost of
scanning; `similarity_search` stays the fast path.

Both halves of that score use cosine similarity whatever `distance=` is set to. That is
LangChain's own convention, shared by every implementation of this interface: under `l2` the
candidate pool is L2-nearest while the selection among those candidates is cosine-based. So
`lambda_mult=1.0` gives back this store's similarity ranking under `cosine` only — under `l2`
and `dot` it reorders the candidate pool by cosine instead.

**Expect to tune `lambda_mult` upward.** The `0.5` default is LangChain's, kept so code ported
from another vector store behaves identically. It weights relevance and variety equally, and
those two terms rarely have equal spread: an embedding model that packs its distances into a
narrow band leaves the variety term varying far more than the relevance term, so variety
quietly decides most picks. On the demo corpus through `text-embedding-3-small` every distance
fell between 0.60 and 0.67, and `0.5` promoted a listing that did not answer the question at
all, while `0.7` and `0.8` both dropped a near-duplicate for a genuine alternative. That is one
corpus and one model — a reason to sweep the value on your own data, not a number to copy.

### Letting an agent search the store

The store is not a tool, but a retriever becomes one with LangChain's own
`create_retriever_tool` — so an agent decides *whether* to search and *what* to search for,
alongside the SQL tools:

```python
from langchain_core.tools.retriever import create_retriever_tool

search_docs = create_retriever_tool(
    store.as_retriever(search_kwargs={"k": 4}),
    name="search_listings",
    description="Find listings whose description matches what the guest is describing.",
)

tools = [*hl.make_hotdata_tools(client, database_id="dbid..."), search_docs]
```

Use a chain when every question needs the corpus — one retrieval, predictable cost. Wrap it as
a tool when the model should choose, reformulate a query, or search more than once.

Note the two return different things: `create_retriever_tool` gives the model concatenated
document text, whereas `hotdata_search_text` returns the `{"metadata", "rows"}` envelope the
other Hotdata tools use, so values from a hit can be carried into a follow-up SQL query.

### Filtering on metadata

Metadata always round-trips in full. To *filter* on a key, declare it up front so it is stored
as a real typed column:

```python
store = hl.HotdataVectorStore(
    client,
    embeddings,
    database_id="dbid...",
    metadata_columns={"city": "string", "beds": "int"},
)

store.similarity_search("bright and quiet", k=3, filter={"city": "sf"})
```

Equality only, for now. Filtering on an undeclared key raises `ValueError` rather than quietly
returning unfiltered results. The predicate goes into the search query itself, not around it —
filtering *after* a top-k selection can only shrink the result, never re-fill it back to `k`.

`metadata_columns` has to match the table it points at. An upsert must carry every column the
table has, so opening an existing store with different promoted columns fails on the first
write with `upload is missing column '<name>'`.

## Scoping queries to an instant database

`database_id=` scopes all SQL the agent runs to one instant database. The API requires a
database scope, so queries fail with `a database is required` without it:

```python
tools = hl.make_hotdata_tools(client, database_id="dbid...")
```

**Databases are addressed by id, never by name.** A database name is a display label and is
not unique, so a name lookup can silently resolve to the wrong database — and the agent's
`hotdata_load_managed_table` overwrites the table it loads into. Passing a name raises
`KeyError` — or, under `handle_errors=True`, returns it to the model rather than resolving
anything. Ids come from `client.list_managed_databases()`, the
`hotdata_list_managed_databases` tool, or the response of a create.

The id is resolved once when the tools are built, so a bad id fails there rather than on the
agent's first query, and no query pays a repeat lookup. If you already hold a
`ManagedDatabase` — from `list_managed_databases()` or `create_managed_database()` — pass it
instead of its id to skip the lookup entirely:

```python
db = hl.create_managed_database(client, name="sales", schema="public", tables=["orders"])
tools = hl.make_hotdata_tools(client, database_id=db)
```

Every helper that runs a query takes the same `database_id=` — `hl.execute_sql_json`,
`hl.bm25_search_json`, `hl.semantic_search_json`, `hl.hybrid_search_json` and
`hl.describe_tables_json` — so working outside the tool layer scopes the same way and accepts
the same id or record. Passing the record skips the lookup an id costs on each call:

```python
print(hl.execute_sql_json(client, "SELECT * FROM orders LIMIT 5", database_id="dbid..."))
```

## Giving an agent more than one database

One client can query many databases, so registering several tool sets in one agent works. Each
set needs a name suffix, or every set registers `hotdata_execute_sql` and the model is handed
tools it cannot tell apart or address:

```python
sales = hl.make_hotdata_tools(client, database_id="dbid...", tool_name_suffix="sales")
support = hl.make_hotdata_tools(
    client, database_id="dbid...", tool_name_suffix="support", management_tools=False
)

agent = create_agent(model=your_model, tools=[*sales, *support])
```

That gives `hotdata_execute_sql_sales` and `hotdata_execute_sql_support`, and every
cross-reference between descriptions follows — the SQL tool points at the schema tool of its own
set, not the other's. A suffix must be letters, digits, underscores or hyphens, and the whole
name has to stay within 64 characters — the shortest tool-name limit among the providers this
package is used with. Both are checked when you build the set rather than when a provider
first rejects a call.

Descriptions name their database too, so the model has something to choose on rather than two
identically-worded tools:

> Works on the 'sales' database. Run a read-only SQL query and return the rows as JSON. …

The name comes from the database record; pass `label=` to override it. A database with no name
gets no such sentence rather than one naming its id. Only the database-scoped tools carry it —
the instant-database tools act on the workspace, so naming one database in them would be false.

`management_tools=False` on the extra sets is worth it for the same reason: listing, creating
and loading databases are workspace-wide, so a second copy of them is redundant surface.

Note that a query cannot reach across databases: `SELECT ... FROM other_db.public.t` from within
one database's scope fails with `table not found`. Each set queries its own.

## Controlling result size

Limit how many rows are returned to the LLM. Useful for keeping responses within context limits (default: 100):

```python
tools = hl.make_hotdata_tools(client, max_rows=50)
```

The cap is stated in the SQL tool's description, and `metadata.row_count` is the total the
query matched *before* it — so a result that was cut says so twice, in the numbers and in
`metadata.client_warning`.

## Warnings about results that are not what they look like

Some calls succeed while quietly meaning something other than what was asked: rows cut at the
cap, a `k` clamped before the search ran, a date format pattern the engine will not interpret.
Each of those returns an ordinary successful result, so nothing signals that anything went
wrong. They are reported in `metadata.client_warning`:

```json
{
  "metadata": {
    "row_count": 7535,
    "warning": null,
    "client_warning": "Returned the first 100 rows of the 7535 this query matched. ..."
  },
  "rows": ["… the first 100 …"]
}
```

`warning` is the engine's own field and is passed through untouched; `client_warning` is this
package's, so the two never overwrite each other and a consumer can tell which side noticed.
The key is absent when there is nothing to say.

The format check is the one worth knowing about outside an agent loop. The engine's date
patterns are strftime, so `to_char(d, 'YYYY-MM-DD')` is not a pattern at all — it returns the
literal text `YYYY-MM-DD` on every row, with no error. Any format pattern containing no `%`
is flagged, with the strftime equivalent when it can be worked out.

The same query applied to a *column* rather than a literal fails instead of returning a wrong
value, and the engine answers with nothing more specific than an internal error. So the hint
is raised on that path too, as a `HotdataToolError` carrying it ahead of the engine's message
— which is what `handle_errors=True` then hands to the model.

## Run the examples

```bash
uv run python examples/langchain_basic.py
uv run python examples/langchain_managed_db.py

# needs an embedding provider key and the langchain-openai integration
uv run --group demo python examples/langchain_vectorstore.py
```

For full end-to-end runs against a real workspace, see [`demo/`](demo/README.md): one takes a
workspace from empty through a data load and BM25 index build to an agent choosing between
search and SQL; the other writes embedded documents into a managed table and answers a
question with a stock LangChain retrieval chain over `HotdataVectorStore`.

## Development

```bash
uv sync --locked
uv run pytest
```
