Python SDK
telem-sdk is a typed Python client (httpx + pydantic v2) for the Telem search
orchestration API. It wraps POST /v1/interactions and reads back the server’s
normalized envelope — one result shape for every provider — with mirrored
synchronous (Telem) and asynchronous (AsyncTelem) clients that share every
behavior.
Install
Section titled “Install”pip install telem-sdkRequires Python 3.12+. Three optional extras add integrations without pulling their dependencies into a plain install:
pip install "telem-sdk[mcp]"pip install "telem-sdk[openai]"pip install "telem-sdk[langchain]"[mcp]— the statelesstelem-mcpstdio server (see MCP server).[openai]— letsclient.wrap()patch a realopenai.OpenAI/AsyncOpenAIclient in place so the model can call Telem search as a tool.[langchain]— addstelem.integrations.langchain.create_telem_search_tool(), a native LangChain/LangGraph tool.
Authentication
Section titled “Authentication”The client resolves both api_key and base_url the same way, first match wins:
- Explicit argument —
Telem(api_key=..., base_url=...). - Environment variable —
TELEM_API_KEY/TELEM_BASE_URL. - Default — no default for
api_key(unset means noAuthorizationheader is sent at all — many deployments accept anonymous requests);TELEM_BASE_URLdefaults to the hosted deployment.
See Authentication for the full resolution rules, including why a missing key isn’t necessarily an error.
from telem import Telem
client = Telem() # reads TELEM_API_KEY / TELEM_BASE_URL from the environment# or, explicitly:client = Telem(api_key="tlm_...", base_url="https://your-router.example.com")Per-search defaults (tier, fields, provider allow/deny lists, full content)
resolve the same three-step way, through their own constructor arguments and
TELEM_TIER / TELEM_FIELDS / TELEM_PROVIDERS_INCLUDE / TELEM_PROVIDERS_EXCLUDE
/ TELEM_FULL_CONTENT env vars — see search() below.
Sync and async clients
Section titled “Sync and async clients”Telem and AsyncTelem expose exactly the same surface — every method,
argument, and default. Pick whichever fits your program; nothing behaves
differently between them beyond await and async with.
from telem import Telem
client = Telem()response = client.search("best python http client")print(len(response.results))client.close()import asynciofrom telem import AsyncTelem
async def main(): async with AsyncTelem() as client: response = await client.search("best python http client") print(len(response.results))
asyncio.run(main())Both support the context-manager form; Telem also needs an explicit
client.close() if you don’t use with.
Search
Section titled “Search”search() performs one round trip (POST /v1/interactions) and returns the
server’s normalized envelope for every provider that ran:
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) include_raw=True, # also attach each provider's own response body goal="brief the user", # merged into request metadata)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 the server’s own default); fields replaces tier
when both would apply, and when both provider halves resolve, the excluded
names are subtracted from the allow-list rather than sent as a separate
deny-list. Nothing is validated client-side — an unknown tier, field, or
provider name comes back as a BadRequestError (see Errors).
Multi-query batching
Section titled “Multi-query batching”query also accepts a sequence of strings. They batch into one interaction
— the backend runs them concurrently — and each provider run in by_provider
is tagged with the query it served:
resp = client.search(["onsen towns near kyoto", "best ramen in kyoto"])
for run in resp.by_provider: print(run.batch_index, run.query, run.provider, len(run.results))resp.results stays the flattened list across every run regardless of which
query produced it. A one-element sequence behaves exactly like a plain string.
SearchResponse shape
Section titled “SearchResponse shape”resp.results # flattened list[SearchResult]: providers in run order, rows in envelope orderresp.by_provider # list[ProviderRun] — the primary surface; keeps partial failuresresp.session_id # continue the conversation by passing session=resp.session_idresp.interaction_id # the interaction this call createdresp.status # "succeeded" | "partially_succeeded" | "failed"resp.normalized_schema_version # the contract the server answered with (>= 2 required)by_provider is a list of ProviderRun — one entry per provider that ran, so a
provider that failed still shows up (with status/error set and an empty
results list) instead of silently vanishing from results. Each ProviderRun
carries provider, status, results, error, latency_ms,
preprocessor_run_id, tier, fields, query, batch_index, plus
provider-native extras (answer, entities, related, verticals, usage,
warnings, raw).
Each SearchResult exposes url, title, summary, excerpt, full_content,
publish_date, rank, thumbnail, favicon, source, enrichments,
fetch_meta, plus provider and raw (the verbatim envelope row).
result.content is a legacy alias — summary, else full_content["content"],
else "" — computed as a property, not a stored field.
Sessions
Section titled “Sessions”client.sessions groups session-scoped reads. list() requires an API key
(the server answers 401 → AuthError otherwise); history() and results()
work against any session id, including one you just got back from search():
client.sessions.list() # list[SessionSummary] (requires an API key)client.sessions.history(session_id) # short historyclient.sessions.history(session_id, full=True) # detailed historyclient.sessions.results(session_id) # aggregated per-query preprocessor resultsSessionSummary carries id, created_at, updated_at, interaction_count,
latest_interaction_at. SessionHistory carries session_id, instance_id,
and a pass-through interactions list. SessionResults carries session_id,
query, goal, context, and previous_preprocessor_results.
AsyncTelem mirrors this exactly as await client.sessions.list(), etc.
Errors
Section titled “Errors”Every error the SDK raises derives from TelemError and carries .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 without
the V2 normalized contract raises TelemServerVersionError.
See Resources → Errors for this same table plus how the OpenClaw/opencode/pi agent-tool plugins surface a failed call, and Resources → Rate limits for the current state of rate limiting.
from telem import Telem, BadRequestError
try: Telem().search("hi", providers_include=["does-not-exist"])except BadRequestError as exc: print(exc.status_code, exc.message)No JavaScript/TypeScript SDK
Section titled “No JavaScript/TypeScript SDK”There is no JavaScript or TypeScript client library, published or otherwise —
don’t npm install anything expecting a Telem SDK. The TS surfaces in this
project (the OpenClaw plugin, the opencode plugin, and the pi package) are
agent-tool plugins: each registers search/fetch tools inside its host
agent, not a general-purpose HTTP client you’d import into your own code. If
you’re wiring Telem into a coding agent rather than calling the API directly,
see the Integrations section — OpenClaw,
opencode, and pi each get their
own page.