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 (launching soon).
Installation
Coming soon:
telem-sdkis not yet published to PyPI, so the command below does not work yet. Request early access at docs.telem.ai — it carries the current install instructions. With repository access, install from a local checkout withuv sync(see CONTRIBUTING.md).
pip install telem-sdk
Requires Python 3.12+.
Quickstart
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 and the opencode plugin, 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.
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
developerrole is sent assystem); - 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,completedorpending) and its arguments.
Each of those fields is truncated at 128 000 characters.
Not sent:
- Tool results. Messages with role
toolare 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_idand LangGraph'sconfigurable.thread_idare hashed; only the derivedsession_keyandfingerprintgo 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:
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:
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:
>>> 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:
>>> 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 nowproviders_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_resultsasks 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:
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
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
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:
pip install "telem-sdk[langchain]"
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:
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:
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:
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:
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:
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 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.
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. Requires the extra:
pip install telem-sdk[openai].
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:
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:
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 and
denies opencode's builtin
webfetch, so page reads flow through Telem too. 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
- 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
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 stdio server exposes the SDK to any MCP host (Claude Code, Claude Desktop, ...). Requires the extra:
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:
claude mcp add telem -e TELEM_BASE_URL=http://localhost:8000 -e TELEM_PROVIDERS=dummy -- telem-mcp
or via .mcp.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:
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
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:
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:
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.
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 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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file telem_sdk-0.1.2.tar.gz.
File metadata
- Download URL: telem_sdk-0.1.2.tar.gz
- Upload date:
- Size: 54.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
317fd5d4d307c0fe03a91c939dfa81d67427270e91636fb19777b7330626ef1f
|
|
| MD5 |
abc78ffceb780ff5b75211f4fe9de7cc
|
|
| BLAKE2b-256 |
fb2dcbeccd70778b1279aadd4da5fe8a6d05db212c03621df29804f5a2b754cd
|
File details
Details for the file telem_sdk-0.1.2-py3-none-any.whl.
File metadata
- Download URL: telem_sdk-0.1.2-py3-none-any.whl
- Upload date:
- Size: 68.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d1933bc522ea2619f7197536eea8b0371284bd30b909d9b362ccded34757f65e
|
|
| MD5 |
b3eb62ef5b83421689c08f5126473207
|
|
| BLAKE2b-256 |
96cd633f8e304ede9c1f24fd12bc1d18fe25292cc35d6d88006e8752b814e882
|