Metadata-Version: 2.5
Name: telem-sdk
Version: 0.1.3
Summary: Python SDK for the Telem search orchestration API.
License: Proprietary
License-File: LICENSE
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2
Provides-Extra: langchain
Requires-Dist: langchain-core>=1; extra == 'langchain'
Requires-Dist: langgraph>=1; extra == 'langchain'
Provides-Extra: mcp
Requires-Dist: mcp<2,>=1.6; extra == 'mcp'
Provides-Extra: openai
Requires-Dist: openai>=1; extra == 'openai'
Description-Content-Type: text/markdown

# Telem Python SDK

A typed Python client for the Telem search orchestration API. It wraps the backend's
interaction endpoint and reads back the server's normalized envelope — one result shape for
every provider — with both synchronous and asynchronous clients.

Full documentation, including quickstarts for every surface (SDK, MCP, OpenClaw, opencode,
pi): [docs.telem.ai](https://docs.telem.ai) (launching soon).

## Installation

```bash
pip install telem-sdk
```

Requires Python 3.10+ — but only from the next release: every version currently on
PyPI (0.1.2 and earlier) still declares 3.12+, so on 3.10 or 3.11 the command above
fails to resolve until that ships. Working from a checkout instead? `uv sync` — see
[CONTRIBUTING.md](CONTRIBUTING.md).

## Quickstart

```python
from telem import Telem

client = Telem()  # reads TELEM_API_KEY and TELEM_BASE_URL from the environment
results = client.search("best python http client").results
for r in results:
    print(r.title, r.url)
```

## Configuration

The client is credential-agnostic and resolves configuration from arguments, then the
environment, then — for credentials only — `~/.telem/credentials.json`, then defaults:

| Setting             | Argument                       | Env var                    | Default                    |
|---------------------|--------------------------------|----------------------------|----------------------------|
| API key             | `api_key`                      | `TELEM_API_KEY`            | `~/.telem/credentials.json`, else none (anonymous) |
| Base URL            | `base_url`                     | `TELEM_BASE_URL`           | `~/.telem/credentials.json`, else `https://router.telem.ai` |
| Result tier         | `default_tier`                 | `TELEM_TIER`               | the server's (`default`)   |
| Explicit fields     | `default_fields`               | `TELEM_FIELDS` (csv)       | unset (the tier decides)   |
| Provider allow-list | `default_providers_include`    | `TELEM_PROVIDERS_INCLUDE` (csv) | the deployment's set  |
| Provider deny-list  | `default_providers_exclude`    | `TELEM_PROVIDERS_EXCLUDE` (csv) | unset                 |
| Full page content   | `default_include_full_content` | `TELEM_FULL_CONTENT` (`1` only) | off                   |

When an API key is set, requests carry an `Authorization: Bearer <key>` header. Anonymous
access works for most endpoints locally; `sessions.list()` requires a token.

`~/.telem/credentials.json` is the machine-written credentials file (`{"apiKey": "tlm_…",
"baseUrl": "https://…"}`) that the guided installer writes, so an installed user never has
to export anything. It supplies each of the two values only when that value is still
unresolved, and it is opened only when at least one of them is — so a client given both
its key and its base URL by argument or env var never touches the disk. A missing or
malformed file simply supplies nothing, and nothing is printed. `TELEM_CONFIG_DIR`
relocates the directory. It is the SDK's **only** config file — repo-local
`.telem/telem.json` search options, which the plugins and the MCP server do read, are
deliberately never read here: a library must not let a checked-out repository steer an
arbitrary program's spend.

Every search default resolves as **call argument → constructor argument → env var →
unset**. A csv env var is split on commas with items stripped and empties dropped, so an
all-empty value reads as unset: only a constructor argument can express an explicit empty
list (`default_fields=[]`, a deliberate "send nothing" the server rejects). `TELEM_TIER`
and `TELEM_FULL_CONTENT` follow the same rule — `TELEM_FULL_CONTENT` enables full content
for exactly the value `1`. `TELEM_PROVIDERS` is NOT read by the SDK: it belongs to the
[MCP server](#mcp-server) and the [opencode plugin](#coding-agent-plugins), where it
survives as a **deprecated alias of the provider allow-list**. Export
`TELEM_PROVIDERS_INCLUDE` for both.

`num_results`, `include_raw` and `provider_overrides` are deliberately call-level only:
per-call intent, an audit knob and a surgical escape hatch respectively.

```python
client = Telem(api_key="tlm_...", base_url="https://router.telem.ai", default_tier="extended")
```

The request timeout defaults to **60 s** (the server grants provider timeouts that long at
the `max` tier and with full content); pass `timeout=` to change it.

## What Telem receives

Telem is a hosted service, so everything described here leaves your process and reaches
Telem's servers. What gets sent depends entirely on which entry point you use.

**A plain `Telem().search()` sends only what you hand it** — the query (or queries), plus
any `goal`, `context` and `metadata` you pass, and the search options themselves. There is
no ambient collection: no conversation, no files, no environment.

**The agent integrations send the conversation.** Both the OpenAI wrap (`client.wrap()`)
and the LangChain/LangGraph tool attach a snapshot of the surrounding conversation to
every search request, as `metadata["message_history"]`. That is what the integrations are
for — it is how the backend sees what the agent is actually working on — but it means the
conversation text is transmitted, so choose them deliberately.

Sent verbatim, per message:

- user, system and assistant message text (OpenAI's `developer` role is sent as `system`);
- provider reasoning text, when the model provider returns it (OpenRouter, DeepSeek, ...);
- one compact marker per tool call — `[tool <name>: <status> <arguments>]` — carrying the
  tool's name, its status (`running`, `completed` or `pending`) and its arguments.

Each of those fields is truncated at 128 000 characters.

Not sent:

- **Tool results.** Messages with role `tool` are dropped entirely. A tool call is
  represented only by its marker; whatever it returned — a file, a page, a database
  row — never reaches Telem through the history.
- **Your conversation identifiers, in raw form.** The OpenAI wrap's `conversation_id` and
  LangGraph's `configurable.thread_id` are hashed; only the derived `session_key` and
  `fingerprint` go on the wire.

**When it is sent: only on a search.** The wrap records messages locally as the
conversation runs, and `wrapped.telem_messages` exposes that recording. Nothing leaves
the process until the model calls `telem_search` and a search request actually goes out.

**Opting out.** `use_telem="none"` on a wrapped `create()` call skips Telem for that call
entirely, and a plain `Telem().search()` never sends history. Beyond those two, there is
no history-free search mode: a search issued from inside a wrapped conversation or a
LangGraph run always carries `metadata["message_history"]`. (A LangChain tool invoked
directly, outside a graph, has no graph state to read and so sends none — but that is the
absence of a conversation, not an opt-out.)

Separately from Telem: queries, model-facing result text and the full
`ToolMessage`/`SearchResponse` artifact can also leave your application through your model
provider or your tracing backend. Review every recipient's retention settings, not just
this one.

## Search

`search()` performs a single round trip (`POST /v1/interactions`) and returns the server's
**normalized envelope** for every provider that ran — every provider's rows come back in
one shape, whatever its own API looks like:

```python
resp = client.search(
    "climate policy 2026",
    tier="extended",             # minimalist | default | extended | max
    providers_include=["exa"],   # omit to use the deployment's default provider set
    num_results=10,              # rows PER PROVIDER (server default 5, range 1..20)
    include_raw=True,            # also attach each provider's own response body
    goal="brief the user",       # merged into request metadata
    context="follow-up query",   # merged into request metadata
)

resp.results        # flattened list[SearchResult]: providers in run order, rows in envelope order
resp.by_provider    # list[ProviderRun] — the primary surface; keeps partial failures
resp.session_id     # continue the conversation by passing session=resp.session_id
resp.status         # "succeeded" | "partially_succeeded" | "failed"
resp.normalized_schema_version   # the contract the server answered with
```

The full option set is `tier`, `fields`, `providers_include`, `providers_exclude`,
`provider_overrides`, `num_results`, `include_raw`, `include_full_content`, plus
`goal`/`context`/`session`/`metadata`. `None` means unset (fall through to the client
default, then to the server's own default); `[]`, `{}`, `False` and `True` are all
explicit values and are sent verbatim. Nothing is pre-validated client-side — tier names,
field names and the `num_results` bounds are the server's call and come back as
`BadRequestError`.

Two options compose rather than stack: a `fields` list **replaces** any `tier` (the level
that set it wins, and `fields` wins a tie), and when both provider halves are set the
excluded names are subtracted from the allow-list, which then fully determines the set.

`provider_overrides` is the per-provider escape hatch: raw parameters merged into ONE
provider's request body, keyed by provider name and written in **that provider's own
vocabulary**, not the SDK's:

```python
resp = client.search("climate policy 2026", provider_overrides={"exa": {"numResults": 2}})
```

An overridden provider gets its `raw` payload attached automatically, so you can see what
the override actually did.

Each `SearchResult` exposes `url`, `title`, `summary`, `excerpt`, `full_content`,
`publish_date`, `rank`, `thumbnail`, `favicon`, `source` (an object: `domain`/`name`/
`author`), `enrichments`, `fetch_meta`, plus `provider` and `raw` (the verbatim envelope
row). `result.content` survives as a **legacy alias** — `summary`, else
`full_content["content"]`, else `""` — as a property, not a stored field: it never appears
in `model_dump()`.

### What you actually get back

Captured from a live deployment (2026-07-28, values trimmed). A default-tier row:

```python
>>> run = resp.by_provider[0]
>>> (run.provider, run.status, run.tier, run.query)
('exa', 'succeeded', 'default', 'best onsen towns near kyoto')
>>> run.results[0].model_dump(exclude_none=True)
{'url': 'https://sugoii-japan.com/best-onsen-towns-near-kyoto',
 'title': 'The 7 Best Onsen Towns Near Kyoto You Have To Explore',
 'rank': 1,
 'summary': 'Here are the best onsen towns near Kyoto highlighted in the article: ...',
 'provider': 'exa', 'raw': {...}}
```

At `tier="max"` the run also carries the query-level fields. Their shapes are exactly
what the server's contract pins — note that `related` is an **object**, not a list:

```python
>>> run = client.search("climate policy 2026", tier="max",
...                     providers_include=["serpapi"]).by_provider[0]
>>> run.related
{'questions': [],
 'searches': ['Climate policy 2026 update', 'Climate policy 2026 summary',
              'Is climate change getting better in 2026', ...]}
>>> run.results[0].publish_date
'2026-01-16'
>>> run.answer        # str | None — filled when the provider returned a direct answer
None
```

`entities` and `verticals` are provider-native blocks (dicts), `usage` is dict **or list**
(parallel reports a list), and `warnings` is a list of `{"code", "message"}` objects — a
provider that cannot supply a requested field says so there (`capability_gap`) instead of
failing the run.

Two things to know when moving from the pre-V2 SDK:

- `providers=` is now `providers_include=` (under V2 a caller-sent provider list on the old
  wire path is a 400).
- `max_results=` is gone. It capped the flattened list client-side *after* paying for every
  row; `num_results` asks the server for a per-provider count, so it is a real cost knob
  rather than a truncation.

Every `search()` checks that the server echoed `normalized_schema_version >= 2` and raises
`TelemServerVersionError` otherwise — a pre-V2 backend, or a dev deployment with no
adapter-backed providers configured, fails loudly instead of returning empty results.

Passing a sequence of queries batches them into a single interaction — the backend runs
them concurrently, and each entry in `by_provider` is tagged with the query it served:

```python
resp = client.search(["query a", "query b"])

for run in resp.by_provider:
    print(run.batch_index, run.query, run.provider, len(run.results))
```

`resp.results` stays the flattened list across all runs. A one-element sequence behaves
exactly like a plain string.

## Providers

```python
for p in client.providers():
    print(p.name, p.active_by_default, p.normalized, p.tiers)
```

`normalized` marks the providers that return the V2 envelope (they are the ones a search
can select), and `tiers` lists the tier names each of them serves.

## Sessions

```python
client.sessions.list()                      # list[SessionSummary] (requires an API key)
client.sessions.history(session_id)         # short history
client.sessions.history(session_id, full=True)  # detailed history
client.sessions.results(session_id)         # aggregated websearch preprocessor results
```

## LangChain and LangGraph integration

Install the optional integration to create a native LangChain tool:

```bash
pip install "telem-sdk[langchain]"
```

```python
from telem import Telem
from telem.integrations.langchain import create_telem_search_tool

client = Telem()
telem_search = create_telem_search_tool(
    client,
    providers_include=["exa"],
    num_results=5,
)
```

Only `query` is exposed in the model's tool schema. Search policy — tier, providers,
result count, metadata, and the other `search()` options — is fixed by application code
when the tool is created. The tool returns compact text to the model and keeps the full
typed `SearchResponse` in `ToolMessage.artifact` for application code.

### LangChain agent

Install `langchain` and the package for your model provider. The current `create_agent`
runtime uses LangGraph internally and accepts the Telem tool directly:

```python
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI

agent = create_agent(ChatOpenAI(model="gpt-5.4-mini"), tools=[telem_search])
result = agent.invoke(
    {"messages": [{"role": "user", "content": "What changed today?"}]},
    config={"configurable": {"thread_id": "application-conversation-42"}},
)
```

### LangGraph ToolNode

Applications using the lower-level graph API can pass the same tool to `ToolNode`; there
is no separate LangGraph implementation:

```python
from langgraph.prebuilt import ToolNode

tool_node = ToolNode([telem_search])
```

Pass an `AsyncTelem` client to create an async-only tool and run the graph with `ainvoke()`.
Telem's typed API errors propagate to LangGraph, whose `ToolNode` error policy can handle
them normally.

### Trajectory v5

When LangGraph executes the tool, its hidden `ToolRuntime` supplies the active message state.
Every search sends a compact conversation snapshot as `metadata.message_history` together
with flat trajectory-v5 identity fields. Configure a stable `thread_id` to keep one
conversation fingerprint across invocations; the raw ID is hashed and never sent directly
to Telem:

```python
config = {"configurable": {"thread_id": "application-conversation-42"}}
```

Appending messages leaves the context-window `session_key` unchanged. The first visible
message identifies the default window generation, so summarization that removes the old
prefix rotates the key. Applications with custom compaction or rewind behavior can set
`configurable.telem_context_window_id` explicitly. Without a thread ID the integration
uses message and tool-call IDs as a best-effort one-shot identity; missing bookkeeping
never prevents a search.

LangGraph has no universal API for discovering that one graph spawned another. A parent
tool can freeze its current state and explicitly link a child with:

```python
from langgraph.prebuilt import ToolRuntime
from telem.integrations.langchain import create_telem_child_config

def delegate_to_child(task: str, runtime: ToolRuntime):
    child_config = create_telem_child_config(
        runtime,
        child_thread_id="child-conversation-7",
    )
    return child_agent.invoke(
        {"messages": [{"role": "user", "content": task}]},
        config=child_config,
    )
```

The helper carries a frozen root-first ancestor chain, so nested children work by calling
it again from the immediate parent. The generated v5 request has no `body.session_id` and
no legacy `metadata.trajectory` block.

LangChain callbacks, tags, and trace metadata use the normal invocation configuration and
therefore work with any compatible tracing handler:

```python
result = agent.invoke(
    {"messages": [{"role": "user", "content": "What changed today?"}]},
    config={
        "callbacks": [callback_handler],
        "tags": ["research"],
        "metadata": {"request_id": "request-123"},
    },
)
```

The factory's `metadata=` is sent to the Telem backend alongside the generated v5 fields.
Its `tags=` and `trace_metadata=` label the LangChain tool run and are sent to callback
handlers, not Telem.

Every graph-executed search sends the conversation — see
[What Telem receives](#what-telem-receives) for exactly which fields go on the wire.

Telem interoperates with Langfuse and other tracing backends through standard LangChain
callbacks, and adds no vendor-specific runtime integration or dependency. Contributors
with repo access will find the tracing setup guide and the manual smoke launchers in
[CONTRIBUTING.md](CONTRIBUTING.md).

## Agent integration (OpenAI wrap)

`client.wrap()` patches an OpenAI client in place (exa-style — the same object is
returned) so the model can call Telem search as a `telem_search` tool. One wrapped
client = one agent conversation = one Telem session; the wrap records the conversation
and sends it as `metadata["message_history"]` with every search — see
[What Telem receives](#what-telem-receives). Requires the extra:
`pip install telem-sdk[openai]`.

```python
from openai import OpenAI
from telem import Telem

client = Telem()
wrapped = client.wrap(OpenAI())   # patched in place; same object returned

r = wrapped.chat.completions.create(model="gpt-4o", messages=msgs)
r.telem_responses                    # list[SearchResponse]; empty if no search ran
wrapped.telem_conversation_id        # this agent's conversation identity
wrapped.telem_session_id             # the backend session, adopted from the first search
wrapped.telem_messages               # recorded conversation snapshot
```

By default the request's `tools` are **replaced** with the `telem_search` tool (caller
tools are dropped, exa parity) and search rounds complete inside `create()`. Agents
with their own tools switch to loop integration:

```python
run_tool = wrapped.wrap_tool_runner(run_my_tool)  # merges tools; create() stops auto-completing

r = wrapped.chat.completions.create(model="gpt-4o", messages=msgs, tools=my_tools)
if r.choices[0].message.tool_calls:
    msgs.append(r.choices[0].message)  # the assistant message that made the tool calls
    for tc in r.choices[0].message.tool_calls:
        msgs.append(run_tool(tc))  # telem_search handled by the SDK, others by run_my_tool
```

Pass `use_telem="none"` on a call to skip Telem for that call. `AsyncTelem.wrap()`
mirrors this for `AsyncOpenAI`; the async runner accepts sync or async tool functions.

Wrap the subagent **at the moment you delegate**, passing the parent wrapped client.
The wrap freezes the parent's conversation right then and carries it as the child's
newest ancestor:

```python
root = client.wrap(OpenAI(), goal="answer the user task")
root.chat.completions.create(model="gpt-4o", messages=root_msgs)

# Inside the parent's tool handler, when it decides to spawn a researcher:
subagent = client.wrap(OpenAI(), parent=root, conversation_id="research-1")
subagent.chat.completions.create(model="gpt-4o", messages=subagent_msgs)
```

Every search the subagent runs then carries `parent_node_key` (the parent's snapshot)
and a root-first `ancestors[]` chain, so the backend stitches parent and child into one
graph. Nested subagents work the same way by passing the spawning subagent as `parent`.

> **Wrap the child when you delegate, not at startup.** The freeze happens at `wrap()`
> time. A child wrapped before its parent has spoken records a delegation with empty
> parent context, and the wrap cannot detect that.

**Conversation identity.** `conversation_id` is auto-minted per wrapped client. Supply
your own whenever the conversation outlives one client object — a web server wrapping a
fresh client per request must pass a stable thread id, or every request looks like a new
conversation. `context_window_id` is the matching override for the context-window
generation; by default the wrap anchors on the first message, so trimming or summarizing
the history starts a new generation on its own.

Streaming is not supported yet: `stream=True` calls emit a `UserWarning` and bypass
Telem entirely (no `telem_search` tool, no session tracking, no reply recording).

## Coding-agent plugins

Three TypeScript plugins give coding agents the same two tools — `telem_search` (web
search, one or more queries per call) and `telem_fetch` (full page text by URL) — over the
same V2 search contract and the same trajectory-v5 session protocol as the OpenAI wrap
above. They are separate npm packages with their own configuration, and none of them needs
this Python package installed:

- **the opencode plugin** — registers both tools in [opencode](https://opencode.ai) and
  denies opencode's builtin `webfetch`, so page reads flow through Telem too.
  [docs.telem.ai/integrations/opencode](https://docs.telem.ai/integrations/opencode/)
- **the pi plugin** — the same two tools for the pi coding agent, plus an installable
  Telem skill with standalone CLI scripts.
  [docs.telem.ai/integrations/pi](https://docs.telem.ai/integrations/pi/)
- **the OpenClaw plugin** — the same two tools for OpenClaw, configurable from OpenClaw's
  own plugin config as well as the environment.
  [docs.telem.ai/integrations/openclaw](https://docs.telem.ai/integrations/openclaw/)

All three resolve their search options per call — an edit takes effect on the next search,
with no restart — from a project `telem.json`, then a home one, then the `TELEM_*`
environment variables. Each plugin's page above documents its own file locations and keys.

## MCP server

A stateless [MCP](https://modelcontextprotocol.io) stdio server exposes the SDK to any
MCP host (Claude Code, Claude Desktop, ...). Requires the extra:

```bash
pip install 'telem-sdk[mcp]'
telem-mcp                    # or: python -m telem.mcp
```

Configuration is env-only:

| Env var                | Meaning                                                              |
|------------------------|----------------------------------------------------------------------|
| `TELEM_BASE_URL`       | API base URL (default `https://router.telem.ai`)                     |
| `TELEM_API_KEY`        | Optional bearer auth; required for `telem_session_history`           |
| `TELEM_PROVIDERS`      | Comma-separated alias for `search.providers.include` (server picks when unset) |
| `TELEM_RESULT_MAX_LEN` | Per-result content cap in characters (default 8000)                  |
| `TELEM_TIMEOUT`        | Request timeout in seconds (default 60, matching the client)         |

The client's own defaults (`TELEM_TIER`, `TELEM_FIELDS`, `TELEM_PROVIDERS_INCLUDE`/
`TELEM_PROVIDERS_EXCLUDE`, `TELEM_FULL_CONTENT`) apply too; an explicit `TELEM_PROVIDERS`
wins over `TELEM_PROVIDERS_INCLUDE` for the include list.

Tools:

| Tool                    | Does                                                                  |
|-------------------------|-----------------------------------------------------------------------|
| `telem_search`          | Web search; batches multiple `queries` into one interaction/session   |
| `telem_providers`       | Lists configured providers, marking the ones active by default        |
| `telem_session_history` | Shows a session's prior searches (status + query per interaction)     |

Sessions are model-threaded: every `telem_search` result leads with its Telem session
id and the model passes it back as `session_id` for every search serving the same user
goal, omitting it (and setting a `goal`) only for a genuinely new goal — the same
contract as the OpenClaw plugin. The reasoning is written up in
`docs/specs/2026-07-23-mcp-session-strategy-design.md` in the repository.

Register with Claude Code:

```bash
claude mcp add telem -e TELEM_BASE_URL=http://localhost:8000 -e TELEM_PROVIDERS=dummy -- telem-mcp
```

or via `.mcp.json`:

```json
{
  "mcpServers": {
    "telem": {
      "command": "telem-mcp",
      "env": {
        "TELEM_BASE_URL": "http://localhost:8000",
        "TELEM_PROVIDERS": "dummy"
      }
    }
  }
}
```

For a local checkout, or to run an unreleased version, go through uv instead:

```bash
claude mcp add telem -e TELEM_BASE_URL=http://localhost:8000 -- uv run --project /path/to/TelemSDK telem-mcp
```

## Agent Skill

`telem-search` is an installable [Claude Code skill](https://docs.claude.com/en/docs/claude-code/skills)
that teaches an agent to search the web by calling this SDK directly — single and
batched searches, session continuation, provider selection — with no MCP server
involved. It ships inside the package (`telem/_skills/telem-search/`); Claude Code
only loads skills from `~/.claude/skills/` or a project's `.claude/skills/`, so a
console script copies it there:

```bash
telem-install-skill              # ~/.claude/skills/telem-search — all projects
telem-install-skill --project    # ./.claude/skills/telem-search — this project only
```

Pass `--force` to replace an existing installation; without it the command refuses
to overwrite one and exits non-zero. The console script comes from the package, so
install it first: `pip install telem-sdk`, or `pip install -e .` from a checkout.

It ships a self-contained one-shot CLI at `scripts/search.py` inside the installed
skill directory.

## Async

`AsyncTelem` mirrors `Telem`; every request method is a coroutine:

```python
import asyncio
from telem import AsyncTelem

async def main():
    async with AsyncTelem() as client:
        resp = await client.search("best python http client")
        print(len(resp.results))

asyncio.run(main())
```

## Errors

All errors derive from `TelemError` and carry `.message`, `.status_code`, and `.body`:

| Status         | Exception          |
|----------------|--------------------|
| 400            | `BadRequestError`  |
| 401 / 403      | `AuthError`        |
| 404            | `NotFoundError`    |
| other non-2xx  | `APIStatusError`   |

Two failures never reach a status code: a request that produced no HTTP response at all
raises `TelemConnectionError`, and a search answered by a pre-V2 server raises
`TelemServerVersionError`.

```python
from telem import Telem, BadRequestError

try:
    Telem().search("hi", providers_include=["does-not-exist"])
except BadRequestError as exc:
    print(exc.status_code, exc.message)
```

## Contributing

Working on the SDK itself? [CONTRIBUTING.md](CONTRIBUTING.md) covers the development
environment, the test suites, linting and the manual smoke launchers. It is written for
people with access to the repository; it ships with the source, not with the package.
