Skip to content

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.

Terminal window
pip install telem-sdk

Requires Python 3.12+. Three optional extras add integrations without pulling their dependencies into a plain install:

Terminal window
pip install "telem-sdk[mcp]"
Terminal window
pip install "telem-sdk[openai]"
Terminal window
pip install "telem-sdk[langchain]"
  • [mcp] — the stateless telem-mcp stdio server (see MCP server).
  • [openai] — lets client.wrap() patch a real openai.OpenAI/AsyncOpenAI client in place so the model can call Telem search as a tool.
  • [langchain] — adds telem.integrations.langchain.create_telem_search_tool(), a native LangChain/LangGraph tool.

The client resolves both api_key and base_url the same way, first match wins:

  1. Explicit argumentTelem(api_key=..., base_url=...).
  2. Environment variableTELEM_API_KEY / TELEM_BASE_URL.
  3. Default — no default for api_key (unset means no Authorization header is sent at all — many deployments accept anonymous requests); TELEM_BASE_URL defaults 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.

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 asyncio
from 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() 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).

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.

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.interaction_id # the interaction this call created
resp.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.

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 history
client.sessions.history(session_id, full=True) # detailed history
client.sessions.results(session_id) # aggregated per-query preprocessor results

SessionSummary 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.

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)

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.