Metadata-Version: 2.5
Name: telem-sdk
Version: 0.1.2
Summary: Python SDK for the Telem search orchestration API.
License: Proprietary
License-File: LICENSE
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.12
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

> **Coming soon:** `telem-sdk` is not yet published to PyPI, so the command below does not
> work yet. Request early access at [docs.telem.ai](https://docs.telem.ai) — it carries the
> current install instructions. With repository access, install from a local checkout with
> `uv sync` (see [CONTRIBUTING.md](CONTRIBUTING.md)).

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

Requires Python 3.12+.

## 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 defaults:

| Setting             | Argument                       | Env var                    | Default                    |
|---------------------|--------------------------------|----------------------------|----------------------------|
| API key             | `api_key`                      | `TELEM_API_KEY`            | none (anonymous)           |
| Base URL            | `base_url`                     | `TELEM_BASE_URL`           | `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.

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"
      }
    }
  }
}
```

While the package is unpublished (or for a local checkout), run it 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. From a checkout, `pip install -e .` first.

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.
